diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..41699be --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,4 @@ +# Local development builds embed the craft-fonts checkout next to this repo when it exists +# (build.rs warns and continues without it). CI and releases set CRAFT_FONTS_DIR explicitly. +[env] +CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } diff --git a/.gitignore b/.gitignore index 2287989..8b89730 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ -plan/ -plans/ *~ +plan/ +/target +/target-*/ +/dist +/corpus +/log +.DS_Store +/.claude/ +/craft-fonts/ +packaging/env.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7bcdc20 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,162 @@ +# AGENTS.md — rules for every agent and contributor + +SlideCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft +PowerPoint parity — and superiority (speed, openness, agent control). It runs natively on macOS, +Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: +PhotoCraft, VectorCraft, FilmCraft, LightCraft, PrintCraft, 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.** + +--- + +## 1. Asset policy (absolute — no exceptions) + +An **asset** is any non-code material the project includes, ships, embeds, generates for +publication or shows in its documentation: icons, images, illustrations, screenshots, logos, +fonts, colour profiles, sounds, video, templates, themes, sample/demo/test documents, and anything +the app renders from those. + +### 1.1 Nothing from Microsoft, Adobe, Avid or Autodesk products + +- **Never** use icons, images, artwork, logos, UI graphics, fonts, templates, themes, clip art, + SmartArt graphics, stock media, cursors, sounds, sample documents or any other asset taken from a + Microsoft, Adobe, Avid or Autodesk product, installer, website, document or package. "Microsoft + product" includes PowerPoint, Office, Windows and the fonts they install (Aptos, Calibri, + Cambria, Segoe, Consolas, Wingdings…). +- This holds even when the vendor publishes the material under an open licence, if it is **visual + design**: SlideCraft does not embed or reference Adobe's Source Sans/Serif/Han families or Noto + CJK (Source Han rebranded); `crates/fonts/build.rs` excludes them. +- **Never** recreate, trace, redraw or closely imitate a vendor icon, theme or graphic. Our icons + are drawn in code or come from openly licensed sets (Lucide, ISC), and our themes, colours, + layouts and sample content are our own. +- Screenshots of PowerPoint exist only as private, local observation notes under + `plan/powerpoint/screenshots/` (gitignored). They must **never** be committed, published, + linked, shown in docs, or traced. +- Never commit files produced by PowerPoint. Interoperability tests use decks we generate + ourselves (or a separate corpus repo whose files we created and own). + +### 1.2 Only open, provable licences + +Every asset must be one of: open source (ISC, MIT, BSD-2/3-Clause, Apache-2.0, OFL-1.1, +Ubuntu-Font-1.0, Bitstream-Vera); Creative Commons (CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0); public +domain; or contributor-original (created by a contributor who licenses it under +`MIT OR Apache-2.0`). + +One narrow exception: the ArtCraft name, wordmark and logos in `docs/brand/` are trademarks of the +ArtCraft Team (not open source, terms in `docs/brand/LICENSE-brand.txt`), used only unmodified to +identify the project. + +Forbidden: system fonts that are not openly licensed, vendor emoji artwork, stock photos, "free +for personal use" material, screenshots of other people's software, anything whose licence you +cannot prove from its original source. + +**Screenshots of SlideCraft** are contributor-original. Everything visible in them must itself be +allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed assets. + +### 1.3 Every asset is attributed + +- **`ATTRIBUTION.md`** (repo root) lists every asset file — committed, fetched at build time + (craft-fonts) or bundled through a Cargo dependency — with author, source (URL + version or + commit), licence and use. `cargo xtask assets` fails if a file under `assets/`, `docs/images/` + 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`). SlideCraft 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`). + +### 1.4 Adding or changing an asset + +1. Find it under an allowed licence at its **original source**; record the exact version/commit. +2. Confirm the author is not Microsoft, Adobe, Avid or Autodesk (§1.1). Keep the licence text. +3. Add the file, its licence file and an `ATTRIBUTION.md` row; run `cargo xtask assets`. +4. If origin or licence is unclear, **do not add it**. + +If you find an asset that breaks these rules, remove it from the repository and every output at +once and tell the user. + +--- + +## 2. Clean-room rules + +- PowerPoint is installed on the dev machine and may be **observed** black-box: run it, use its UI + on synthetic decks, screenshot windows by id into `plan/powerpoint/screenshots/` (never + committed). Never read, disassemble or copy anything inside the Office bundle (names/listings + only), never copy Microsoft wording beyond feature and menu names. +- File formats come from public standards: ECMA-376 / ISO/IEC 29500 (Office Open XML), ISO 32000 + (PDF). Preset shape geometry is re-derived by us in `crates/geom/src/preset.rs`. +- Never copy GPL/AGPL/LGPL/MPL code (LibreOffice, Calligra…). Permissive crates are fine as + dependencies. Code may be copied (not depended on) from sibling Craft apps. + +## 3. Never crash + +People trust SlideCraft 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)]`. +- Errors are `Result` + `?`. Input-derived numbers (files, commands, MCP/control params) are + hostile: `get()` not `[i]`, checked arithmetic, no NaN casts, capped allocations, bounded + recursion. +- `Session::execute` runs every command inside a panic guard (`crates/engine/src/guard.rs`) that + keeps the presentation as it was. It is a safety net, not a licence. +- Every crash fix lands with a regression test. + +## 4. Architecture + +| Layer | Crates | +|---|---| +| L0 | `geom` (units, transforms, preset shapes), `color` | +| L1 | `model` (presentation, masters, layouts, slides, shapes, text, themes, inheritance, text edit ops) | +| L2 | `fonts`, `text` (layout), | +| L3 | `render` (vello_cpu) | +| L4 | `format` (.slidecraft), `pptx` | +| L5 | `engine` (session, history, commands, tools, keys, sample) | +| L6 | `mcp` | +| L7 | `ui-egui` (swappable; nothing below depends on egui/eframe/winit/rfd) | +| apps | `slidecraft` (desktop), `slidecraft-cli`, `slidecraft-web` | + +- **Everything is a command** (`crates/engine/src/cmd/*`): id, label, ribbon/menu place, + shortcut, params doc, `enabled`, `run`, plus tests. Ids follow PowerPoint's ribbon and menus + (`slide.new`, `format.bold`, `arrange.group`, `transition.set`…). Programmatic calls never open + dialogs. Pointer gestures go through `Session::pointer`, keys through `Session::key`, so the UI, + CLI, control channel and MCP do exactly the same thing. +- **The UI is thin**: panels read engine state and act through commands. Colours come from theme + tokens. **Never use Tauri**; egui only. +- **Never break wasm** (`cargo xtask wasm`). + +## 5. Quality gates (before every commit) + +`cargo xtask ci` = fmt check, clippy `-D warnings`, tests, layering check, asset check, wasm +check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push +to `main`. + +## 6. Running and looking at the app + +- `cargo run --release -p slidecraft -- --sample --control 7979` (sample deck + control channel). +- Drive it: JSON lines on `127.0.0.1:7979`, e.g. + `{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}` + then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: + `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- **For UI work, look at the result** (screenshot, read the PNG) and compare with the observations + in `plan/powerpoint/01-observed-ui.md`. +- Headless: `slidecraft-cli render --sample --all --scale 1 out/`, + `slidecraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`. +- MCP: `slidecraft-cli mcp` (headless) or `slidecraft-cli mcp --connect 7979` (drive the app). + See `docs/mcp.md`. +- Shell gotcha on the dev machine: `mv`/`cp` are aliased interactive; use `/bin/mv -f`, `/bin/cp -f`. +- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own. + +## 7. Planning + +`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`, +`STATUS.md` (start every session there) and `powerpoint/` observations. `ROADMAP.md` (committed) +tracks status, milestones, parity and estimates; update it whenever a milestone task lands. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 0000000..9ee19cd --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,44 @@ +# Attribution + +Every asset SlideCraft includes, bundles, embeds at build time or shows in its documentation, with +its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. `cargo xtask assets` +fails if a file under `assets/`, `docs/images/` or `docs/brand/` is missing from this table. + +SlideCraft contains **no Microsoft, Adobe, Avid or Autodesk iconography, images, fonts, themes, +templates or other assets**. Its themes, colour schemes, layouts, preset-shape geometry, sample +deck and UI icons are original work of the SlideCraft contributors (drawn or generated in code). + +## In this repository + +| Asset | Author | Source | Licence | Used for | +|---|---|---|---|---| +| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` (not open source) | README header | +| `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 | + +## Bundled through dependencies + +| Asset | Author | Source | Licence | Used for | +|---|---|---|---|---| +| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback | +| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs | + +## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`) + +Release builds (and local builds with a craft-fonts checkout) embed the fonts listed in +[craft-fonts](https://github.com/storytold/craft-fonts) `fonts/latin-manifest.txt` and +`fonts/manifest.txt` (per-file authors, sources, SHA-256 and licences in its +[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)), except the +families excluded by `crates/fonts/build.rs`: + +| Families | Licence | Notes | +|---|---|---| +| Inter | OFL-1.1 | Default theme font | +| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria | +| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New | +| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text | + +Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..820317c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +# SlideCraft — working notes for Claude + +Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and +win over anything here. + +## 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. + +## Habits +- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`). +- Look at UI changes: `slidecraft --sample --control 7979` + `ui.screenshot`, or + `slidecraft-cli render`. +- Commit after each landed arc (`M.: what`) and push to `origin main`. +- Keep `ROADMAP.md` and `plan/STATUS.md` current. +- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4b22afc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,956 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "epaint_default_fonts" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glifo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "png", + "skrifa 0.44.0", + "smallvec", + "vello_common", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948d0741125ba89cd3e1c23e5642415b6ade7e1d29d67ba25fb925b533e989d6" +dependencies = [ + "bitflags", + "bytemuck", + "once_cell", + "read-fonts 0.43.3", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "serde", + "smallvec", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-channel" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab" +dependencies = [ + "crossbeam-channel", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.43.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +dependencies = [ + "bytemuck", + "read-fonts 0.44.0", +] + +[[package]] +name = "slidecraft-cli" +version = "0.1.0" +dependencies = [ + "serde_json", + "slidecraft-engine", + "slidecraft-mcp", +] + +[[package]] +name = "slidecraft-color" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "slidecraft-engine" +version = "0.1.0" +dependencies = [ + "image", + "log", + "serde", + "serde_json", + "slidecraft-color", + "slidecraft-fonts", + "slidecraft-format", + "slidecraft-geom", + "slidecraft-model", + "slidecraft-pptx", + "slidecraft-render", + "slidecraft-text", + "thiserror", +] + +[[package]] +name = "slidecraft-fonts" +version = "0.1.0" +dependencies = [ + "epaint_default_fonts", + "harfrust", + "kurbo", + "log", + "skrifa 0.47.0", + "unicode-bidi", +] + +[[package]] +name = "slidecraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "slidecraft-model", + "thiserror", + "zip", +] + +[[package]] +name = "slidecraft-geom" +version = "0.1.0" +dependencies = [ + "kurbo", + "serde", +] + +[[package]] +name = "slidecraft-mcp" +version = "0.1.0" +dependencies = [ + "log", + "serde_json", + "slidecraft-engine", + "slidecraft-render", +] + +[[package]] +name = "slidecraft-model" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "slidecraft-color", + "slidecraft-geom", + "thiserror", +] + +[[package]] +name = "slidecraft-pptx" +version = "0.1.0" +dependencies = [ + "log", + "quick-xml", + "slidecraft-color", + "slidecraft-geom", + "slidecraft-model", + "thiserror", + "zip", +] + +[[package]] +name = "slidecraft-render" +version = "0.1.0" +dependencies = [ + "image", + "kurbo", + "log", + "slidecraft-color", + "slidecraft-fonts", + "slidecraft-geom", + "slidecraft-model", + "slidecraft-text", + "vello_common", + "vello_cpu", +] + +[[package]] +name = "slidecraft-text" +version = "0.1.0" +dependencies = [ + "slidecraft-color", + "slidecraft-fonts", + "slidecraft-geom", + "slidecraft-model", + "unicode-linebreak", + "unicode-segmentation", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "vello_common" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "png", + "smallvec", + "thiserror", +] + +[[package]] +name = "vello_cpu" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" +dependencies = [ + "bytemuck", + "crossbeam-channel", + "glifo", + "hashbrown", + "ordered-channel", + "png", + "rayon", + "thread_local", + "vello_common", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "zerocopy" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..af8de29 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,79 @@ +[workspace] +resolver = "3" +members = ["crates/*", "apps/*"] +default-members = ["crates/*", "apps/slidecraft-cli"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.90" +repository = "https://github.com/storytold/slidecraft" + +[workspace.dependencies] +# internal +slidecraft-geom = { path = "crates/geom" } +slidecraft-color = { path = "crates/color" } +slidecraft-model = { path = "crates/model" } +slidecraft-fonts = { path = "crates/fonts" } +slidecraft-text = { path = "crates/text" } +slidecraft-render = { path = "crates/render" } +slidecraft-pptx = { path = "crates/pptx" } +slidecraft-format = { path = "crates/format" } +slidecraft-engine = { path = "crates/engine" } +slidecraft-mcp = { path = "crates/mcp" } +slidecraft-ui-egui = { path = "crates/ui-egui" } + +# foundation +kurbo = { version = "0.13", features = ["serde"] } +thiserror = "2" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["float_roundtrip"] } +log = "0.4" +proptest = "1" +rayon = "1" +unicode-bidi = "0.3" +unicode-segmentation = "1" +unicode-linebreak = "0.1" + +# text / rendering / formats +skrifa = "0.47" +harfrust = "0.13" +vello_cpu = "0.2" +vello_common = { version = "0.2", default-features = false } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } +krilla = { version = "0.8", features = ["pdf"] } +quick-xml = "0.38" +zip = { version = "3", default-features = false, features = ["deflate"] } + +# ui +egui = "0.36" +egui_extras = { version = "0.36", default-features = false } +eframe = { version = "0.36", default-features = false } +egui_kittest = "0.36" +rfd = "0.17" +arboard = { version = "3.6", default-features = false } + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +dbg_macro = "warn" +todo = "warn" +too_many_arguments = "allow" +type_complexity = "allow" +self_named_constructors = "allow" +large_enum_variant = "allow" +result_large_err = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 16 +debug = 0 diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..d973456 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArtCraft Team and the SlideCraft contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..28813f3 --- /dev/null +++ b/NOTICE @@ -0,0 +1,21 @@ +SlideCraft +Copyright (c) 2026 ArtCraft Team and the SlideCraft contributors. +Licensed under the MIT License or the Apache License, Version 2.0, at your option +(LICENSE-MIT, LICENSE-APACHE). + +The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are +not open source and are not covered by the license above; their terms are in +docs/brand/LICENSE-brand.txt. + +Third-party material + +This product bundles third-party material under its own licenses, listed below. The per-file +list (author, source, license) is in ATTRIBUTION.md. + +- Ubuntu Light (via the epaint_default_fonts crate) — Ubuntu Font Licence 1.0. +- Hack, Noto Emoji and the egui emoji icon font (via epaint_default_fonts) — MIT, Bitstream Vera, + OFL-1.1. +- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in + ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto, + Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans + Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages. diff --git a/README.md b/README.md index cd5650b..0ee333c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,160 @@ -SlideCraft -=========== +

+ + + + ArtCraft + + +

-By ArtCraft +

SlideCraft

+ +

+ Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust. +

+ +

+ Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations, + open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux + and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents + (through MCP) can drive. +

+ +

+ Status: early development + License: MIT OR Apache-2.0 + Written in Rust + Platforms +

+ +

+ Join the ArtCraft community on Discord +

+ +

+ SlideCraft on getartcraft.com · + ArtCraft · + All Crafting Apps +

+ +> [!NOTE] +> **ArtCraft is a community of artists from all walks of life.** Painters, photographers, +> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil +> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).** + +

+ What works · + Getting started · + Agents, CLI and MCP · + Roadmap · + The Crafting Apps · + License +

+ +## What works today + +SlideCraft is in early development. Today: + +- **Presentation model** with slide masters, eleven layouts, placeholders that inherit position and + formatting, themes (colour, font and effect schemes), sections, notes, comments, transitions and + animations. +- **150+ preset shapes** (rectangles, basic shapes, block arrows, equation shapes, flowchart, stars + and banners, callouts, action buttons) with adjust handles, re-derived from scratch. +- **Text engine**: shaping with font fallback, line breaking, bullets and numbering, indents and + spacing, alignment, anchoring, columns, shrink-on-overflow and resize-to-fit. +- **Renderer** (vello_cpu): gradients, pictures with crop and corrections, outlines with dashes and + arrowheads, shadows, glow, soft edges, reflections, tables with styles, charts. +- **Editing engine**: 200+ commands, undo/redo, clipboard, grouping, align and distribute, smart + guides, Format Painter, in-place text editing — all scriptable. + +## Getting started + +```sh +git clone https://github.com/storytold/slidecraft +cd slidecraft +cargo run --release -p slidecraft-cli -- render --sample --all --scale 1 out/ +``` + +Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts): clone it next to this repo +(`../craft-fonts`) and local builds pick it up automatically; without it SlideCraft uses your system +fonts. + +## Agents, CLI and MCP + +Every action in SlideCraft is a command with a stable id, so people, scripts and AI agents use the +same verbs: + +```sh +slidecraft-cli commands format. # list commands +slidecraft-cli run --sample --cmd 'slide.new={"layout":"titleOnly","title":"Hello"}' --save hello.slidecraft +slidecraft-cli mcp # MCP server over stdio (headless) +slidecraft-cli mcp --connect 7979 # drive the running app (slidecraft --control 7979) +``` + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for milestones, parity estimates and what's next. + +## The Crafting Apps + +SlideCraft is one of the **Crafting Apps**: free, open-source creative tools from the +[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to +stand on its own. + +| | App | What it's for | Code | Learn more | +|:-:|---|---|---|---| +| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | +| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | +| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | +| | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | +| | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) | +| | **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) | +| | **SlideCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/slidecraft) | [Website](https://getartcraft.com/apps/slidecraft) | + +And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. + +
+ +

+ Join the ArtCraft community on Discord +

+ +

Come make things with us

+ +

+ Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film, + set type, and people still figuring out what they like to make. Share what you're working on, + ask for help, tell us what's broken, or tell us what you wish these tools could do. + Whatever your medium and however long you've been at it, you're welcome here. +

+ +

+ discord.gg/artcraft · + getartcraft.com · + The Crafting Apps · + SlideCraft +

+ +## License and credits + +SlideCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option. +Copyright (c) 2026 ArtCraft Team and the SlideCraft contributors. Required notices are in [NOTICE](NOTICE). + +Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed +with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). + +SlideCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or +generated in code; no Microsoft artwork, fonts, themes or templates are used. + +The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the +ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as +part of this repository and SlideCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). +Forks and modified versions must remove them. + +Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SlideCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with. + +

+ ArtCraft
+ Made by the ArtCraft team and community. +

diff --git a/apps/slidecraft-cli/Cargo.toml b/apps/slidecraft-cli/Cargo.toml new file mode 100644 index 0000000..b1f9d36 --- /dev/null +++ b/apps/slidecraft-cli/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "slidecraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft on the command line: render, convert, run commands, inspect, and serve MCP" +publish = false + +[lints] +workspace = true + +[[bin]] +name = "slidecraft-cli" +path = "src/main.rs" + +[dependencies] +slidecraft-engine = { workspace = true } +slidecraft-mcp = { workspace = true } +serde_json = { workspace = true } diff --git a/apps/slidecraft-cli/src/main.rs b/apps/slidecraft-cli/src/main.rs new file mode 100644 index 0000000..be37bc7 --- /dev/null +++ b/apps/slidecraft-cli/src/main.rs @@ -0,0 +1,280 @@ +//! Headless SlideCraft. +//! +//! ```text +//! slidecraft-cli info FILE # slides, titles, layouts (JSON) +//! slidecraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR +//! slidecraft-cli convert IN OUT # .slidecraft ⇄ .pptx, outline .txt, .png (first slide) +//! slidecraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] +//! slidecraft-cli commands [FILTER] # list commands (JSON) +//! slidecraft-cli describe ID # one command +//! slidecraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app +//! slidecraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio +//! slidecraft-cli links | --version +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::process::ExitCode; + +use serde_json::{Value, json}; +use slidecraft_engine::Session; + +macro_rules! outln { + ($($arg:tt)*) => {{ + use std::io::Write as _; + if let Err(e) = writeln!(std::io::stdout(), $($arg)*) { + if e.kind() == std::io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + eprintln!("slidecraft-cli: can't write to stdout: {e}"); + std::process::exit(1); + } + }}; +} + +const USAGE: &str = "usage: slidecraft-cli info FILE + slidecraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT + slidecraft-cli convert IN OUT + slidecraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print] + slidecraft-cli commands [FILTER] + slidecraft-cli describe COMMAND + slidecraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] + slidecraft-cli mcp [--connect PORT] [--sample] + slidecraft-cli links | --version"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let rest = args.get(1..).unwrap_or(&[]); + match args.first().map(String::as_str) { + Some("--version" | "-V" | "version") => { + outln!("slidecraft-cli {}", env!("CARGO_PKG_VERSION")); + ExitCode::SUCCESS + } + Some("info") => report(info(rest)), + Some("render") => report(render(rest)), + Some("convert") => report(convert(rest)), + Some("run") => report(run(rest)), + Some("commands") => { + let s = Session::new(); + let f = rest.first().map(|x| x.to_lowercase()); + let list: Vec = s + .commands() + .into_iter() + .filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str()))) + .map(|c| serde_json::to_value(c).unwrap_or_default()) + .collect(); + outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default()); + ExitCode::SUCCESS + } + Some("describe") => report(describe(rest.first().map(String::as_str))), + Some("app") => report(app(rest)), + Some("mcp") => report(mcp(rest)), + Some("links") => { + use slidecraft_engine::links::*; + outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); + ExitCode::SUCCESS + } + _ => { + eprintln!("{USAGE}"); + eprintln!("\nCommunity: {} · {}", slidecraft_engine::links::DISCORD, slidecraft_engine::links::APP_PAGE); + ExitCode::FAILURE + } + } +} + +fn report(r: Result<(), String>) -> ExitCode { + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("slidecraft-cli: {e}"); + ExitCode::FAILURE + } + } +} + +fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String> { + if sample { + return slidecraft_engine::sample::open_sample(s).map_err(|e| e.to_string()); + } + match input { + Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()), + None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()), + } +} + +fn info(args: &[String]) -> Result<(), String> { + let path = args.first().ok_or("info needs a FILE")?; + let mut s = Session::new(); + open(&mut s, Some(path), false)?; + let v = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?; + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + Ok(()) +} + +fn render(args: &[String]) -> Result<(), String> { + let mut input = None; + let mut sample = false; + let mut slide: Option = None; + let mut all = false; + let mut scale = 2.0; + let mut edit = false; + let mut out = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--sample" => sample = true, + "--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?), + "--all" => all = true, + "--edit" => edit = true, + "--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?, + other + if input.is_none() + && !sample + && !other.starts_with("--") + && out.is_none() + && !(other.ends_with(".png") || other.ends_with(".jpg")) + || (input.is_none() && !sample && std::path::Path::new(other).is_file()) => + { + input = Some(other.to_string()) + } + other => out = Some(other.to_string()), + } + } + let out = out.ok_or("render needs an output path")?; + let mut s = Session::new(); + open(&mut s, input.as_deref(), sample)?; + let st = s.doc().map_err(|e| e.to_string())?; + let n = st.doc.slides.len(); + let list: Vec = if all { (0..n).collect() } else { vec![slide.unwrap_or(0)] }; + if all { + std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?; + } + for i in list { + if i >= n { + return Err(format!("no slide {i} (the deck has {n})")); + } + let (png, w, h) = slidecraft_engine::cmd::file::render_png(&st.doc, i, scale, edit); + let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() }; + std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?; + outln!("{path} ({w}×{h})"); + } + Ok(()) +} + +fn convert(args: &[String]) -> Result<(), String> { + let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) }; + let mut s = Session::new(); + open(&mut s, Some(input), false)?; + let fmt = slidecraft_engine::cmd::file::format_for_path(out); + let r = if matches!(fmt, "png" | "jpeg") { + s.execute("file.export", &json!({"path": out, "slide": 0})) + } else { + s.execute("file.saveAs", &json!({"path": out})) + }; + r.map_err(|e| e.to_string())?; + outln!("{out}"); + Ok(()) +} + +fn run(args: &[String]) -> Result<(), String> { + let mut input = None; + let mut sample = false; + let mut cmds = vec![]; + let mut save = None; + let mut export = None; + let mut print = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?), + "--sample" => sample = true, + "--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?), + "--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?), + "--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?), + "--print" => print = true, + other => return Err(format!("unknown option `{other}`")), + } + } + 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}"); + } + } + if let Some(p) = save { + s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?; + } + if let Some(p) = export { + s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn describe(id: Option<&str>) -> Result<(), String> { + let id = id.ok_or("describe needs a command id")?; + let c = slidecraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?; + outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params); + Ok(()) +} + +fn app(args: &[String]) -> Result<(), String> { + use slidecraft_mcp::{Backend, Remote, control_addr}; + let mut port = slidecraft_mcp::DEFAULT_PORT.to_string(); + let mut method: Option = None; + let mut rest = vec![]; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--port" => port = it.next().cloned().ok_or("--port needs a value")?, + "--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?), + other => rest.push(other.to_string()), + } + } + let mut r = Remote::connect(&control_addr(&port)) + .map_err(|e| format!("no SlideCraft app on port {port} ({e}); start it with `slidecraft --control {port}`"))?; + let parse = |s: Option<&String>| -> Result { + s.map(|j| serde_json::from_str(j).map_err(|e| format!("bad JSON: {e}"))).transpose().map(|v| v.unwrap_or(json!({}))) + }; + let v = match method { + Some(m) => r.call(&m, parse(rest.first())?)?, + None => { + let id = rest.first().ok_or("app needs a COMMAND or --method")?; + r.call("engine.execute", json!({"command": id, "params": parse(rest.get(1))?}))? + } + }; + outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + Ok(()) +} + +fn mcp(args: &[String]) -> Result<(), String> { + use slidecraft_mcp::{Backend, Headless, Remote, Server, control_addr}; + let mut connect: Option = None; + let mut sample = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), + "--sample" => sample = true, + other => return Err(format!("unknown mcp option `{other}`")), + } + } + let backend: Box = match connect { + Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?), + None => { + let mut h = Headless::new(); + if sample { + slidecraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?; + } + Box::new(h) + } + }; + let mut server = Server::new(backend); + let stdin = std::io::stdin(); + server.serve(stdin.lock(), std::io::stdout()).map_err(|e| e.to_string()) +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..4b6c79f --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml new file mode 100644 index 0000000..f59d39d --- /dev/null +++ b/crates/color/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "slidecraft-color" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft colour: RGB/HSL, theme colour slots, DrawingML-style colour transforms" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs new file mode 100644 index 0000000..b6a318f --- /dev/null +++ b/crates/color/src/lib.rs @@ -0,0 +1,575 @@ +//! SlideCraft colour: sRGB colours, theme colour slots and DrawingML-style colour transforms +//! (tint, shade, luminance modulation/offset, alpha…), so theme-relative colours resolve the way +//! presentation files expect. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// An sRGB colour with straight alpha, components 0–255. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Rgba { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl Default for Rgba { + fn default() -> Self { + Rgba::BLACK + } +} + +impl Rgba { + pub const BLACK: Rgba = Rgba::rgb(0, 0, 0); + pub const WHITE: Rgba = Rgba::rgb(255, 255, 255); + pub const TRANSPARENT: Rgba = Rgba { r: 0, g: 0, b: 0, a: 0 }; + + pub const fn rgb(r: u8, g: u8, b: u8) -> Self { + Rgba { r, g, b, a: 255 } + } + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Rgba { r, g, b, a } + } + /// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`). + pub fn from_hex(s: &str) -> Option { + let s = s.trim().trim_start_matches('#'); + if !s.is_ascii() { + return None; + } + let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); + match s.len() { + 6 => Some(Rgba::rgb(byte(0)?, byte(2)?, byte(4)?)), + 8 => Some(Rgba::rgba(byte(0)?, byte(2)?, byte(4)?, byte(6)?)), + 3 => { + let n = |i: usize| s.get(i..i + 1).and_then(|h| u8::from_str_radix(h, 16).ok()).map(|v| v * 17); + Some(Rgba::rgb(n(0)?, n(1)?, n(2)?)) + } + _ => None, + } + } + /// `RRGGBB` uppercase (no alpha). + pub fn hex(&self) -> String { + format!("{:02X}{:02X}{:02X}", self.r, self.g, self.b) + } + pub fn with_alpha(self, a: u8) -> Self { + Rgba { a, ..self } + } + pub fn to_f32(self) -> [f32; 4] { + [self.r as f32 / 255.0, self.g as f32 / 255.0, self.b as f32 / 255.0, self.a as f32 / 255.0] + } + pub fn from_f64(r: f64, g: f64, b: f64, a: f64) -> Self { + let c = |v: f64| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }; + Rgba { r: c(r), g: c(g), b: c(b), a: c(a) } + } + /// Relative luminance (WCAG), 0–1. + pub fn luminance(self) -> f64 { + let lin = |c: u8| { + let v = c as f64 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b) + } + /// WCAG contrast ratio between two colours (1–21). + pub fn contrast(self, other: Rgba) -> f64 { + let (a, b) = (self.luminance(), other.luminance()); + let (hi, lo) = if a > b { (a, b) } else { (b, a) }; + (hi + 0.05) / (lo + 0.05) + } + pub fn to_hsl(self) -> (f64, f64, f64) { + let r = self.r as f64 / 255.0; + let g = self.g as f64 / 255.0; + let b = self.b as f64 / 255.0; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + if (max - min).abs() < 1e-12 { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if (max - r).abs() < 1e-12 { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if (max - g).abs() < 1e-12 { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h * 60.0, s, l) + } + pub fn from_hsl(h: f64, s: f64, l: f64, a: u8) -> Self { + let h = ((h % 360.0) + 360.0) % 360.0 / 360.0; + let s = s.clamp(0.0, 1.0); + let l = l.clamp(0.0, 1.0); + if s == 0.0 { + return Rgba::from_f64(l, l, l, a as f64 / 255.0); + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let f = |t: f64| { + let t = if t < 0.0 { + t + 1.0 + } else if t > 1.0 { + t - 1.0 + } else { + t + }; + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + Rgba::from_f64(f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0), a as f64 / 255.0) + } + /// Linear interpolation (straight alpha). + pub fn lerp(self, o: Rgba, t: f64) -> Rgba { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let m = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * t).round().clamp(0.0, 255.0) as u8; + Rgba { r: m(self.r, o.r), g: m(self.g, o.g), b: m(self.b, o.b), a: m(self.a, o.a) } + } +} + +/// The twelve theme colour slots, plus the mapped aliases (`bg1`, `tx1`…) and `phClr` (the +/// placeholder colour a style matrix entry is applied with). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SchemeSlot { + Dk1, + Lt1, + Dk2, + Lt2, + Accent1, + Accent2, + Accent3, + Accent4, + Accent5, + Accent6, + Hlink, + FolHlink, + Bg1, + Tx1, + Bg2, + Tx2, + PhClr, +} + +impl SchemeSlot { + pub const THEME: [SchemeSlot; 12] = [ + SchemeSlot::Dk1, + SchemeSlot::Lt1, + SchemeSlot::Dk2, + SchemeSlot::Lt2, + SchemeSlot::Accent1, + SchemeSlot::Accent2, + SchemeSlot::Accent3, + SchemeSlot::Accent4, + SchemeSlot::Accent5, + SchemeSlot::Accent6, + SchemeSlot::Hlink, + SchemeSlot::FolHlink, + ]; + pub fn xml_name(self) -> &'static str { + match self { + SchemeSlot::Dk1 => "dk1", + SchemeSlot::Lt1 => "lt1", + SchemeSlot::Dk2 => "dk2", + SchemeSlot::Lt2 => "lt2", + SchemeSlot::Accent1 => "accent1", + SchemeSlot::Accent2 => "accent2", + SchemeSlot::Accent3 => "accent3", + SchemeSlot::Accent4 => "accent4", + SchemeSlot::Accent5 => "accent5", + SchemeSlot::Accent6 => "accent6", + SchemeSlot::Hlink => "hlink", + SchemeSlot::FolHlink => "folHlink", + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + SchemeSlot::PhClr => "phClr", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "dk1" => SchemeSlot::Dk1, + "lt1" => SchemeSlot::Lt1, + "dk2" => SchemeSlot::Dk2, + "lt2" => SchemeSlot::Lt2, + "accent1" => SchemeSlot::Accent1, + "accent2" => SchemeSlot::Accent2, + "accent3" => SchemeSlot::Accent3, + "accent4" => SchemeSlot::Accent4, + "accent5" => SchemeSlot::Accent5, + "accent6" => SchemeSlot::Accent6, + "hlink" => SchemeSlot::Hlink, + "folHlink" => SchemeSlot::FolHlink, + "bg1" => SchemeSlot::Bg1, + "tx1" => SchemeSlot::Tx1, + "bg2" => SchemeSlot::Bg2, + "tx2" => SchemeSlot::Tx2, + "phClr" => SchemeSlot::PhClr, + _ => return None, + }) + } + pub fn label(self) -> &'static str { + match self { + SchemeSlot::Dk1 | SchemeSlot::Tx1 => "Text/Background - Dark 1", + SchemeSlot::Lt1 | SchemeSlot::Bg1 => "Text/Background - Light 1", + SchemeSlot::Dk2 | SchemeSlot::Tx2 => "Text/Background - Dark 2", + SchemeSlot::Lt2 | SchemeSlot::Bg2 => "Text/Background - Light 2", + SchemeSlot::Accent1 => "Accent 1", + SchemeSlot::Accent2 => "Accent 2", + SchemeSlot::Accent3 => "Accent 3", + SchemeSlot::Accent4 => "Accent 4", + SchemeSlot::Accent5 => "Accent 5", + SchemeSlot::Accent6 => "Accent 6", + SchemeSlot::Hlink => "Hyperlink", + SchemeSlot::FolHlink => "Followed Hyperlink", + SchemeSlot::PhClr => "Placeholder colour", + } + } +} + +/// A colour scheme: the twelve theme colours. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorScheme { + pub name: String, + pub colors: [Rgba; 12], +} + +impl ColorScheme { + pub fn get(&self, slot: SchemeSlot) -> Rgba { + let i = match slot { + SchemeSlot::Dk1 | SchemeSlot::Tx1 => 0, + SchemeSlot::Lt1 | SchemeSlot::Bg1 => 1, + SchemeSlot::Dk2 | SchemeSlot::Tx2 => 2, + SchemeSlot::Lt2 | SchemeSlot::Bg2 => 3, + SchemeSlot::Accent1 => 4, + SchemeSlot::Accent2 => 5, + SchemeSlot::Accent3 => 6, + SchemeSlot::Accent4 => 7, + SchemeSlot::Accent5 => 8, + SchemeSlot::Accent6 => 9, + SchemeSlot::Hlink => 10, + SchemeSlot::FolHlink => 11, + SchemeSlot::PhClr => 4, + }; + self.colors.get(i).copied().unwrap_or(Rgba::BLACK) + } + pub fn set(&mut self, slot: SchemeSlot, c: Rgba) { + if let Some(i) = SchemeSlot::THEME.iter().position(|s| *s == slot) + && let Some(v) = self.colors.get_mut(i) + { + *v = c; + } + } +} + +/// One colour transform, in file units (percentages ×1000, angles ×60000), applied in order. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "op", content = "val")] +pub enum ColorTransform { + Tint(i32), + Shade(i32), + LumMod(i32), + LumOff(i32), + SatMod(i32), + SatOff(i32), + HueMod(i32), + HueOff(i32), + Alpha(i32), + AlphaMod(i32), + AlphaOff(i32), + Comp, + Inv, + Gray, + RedMod(i32), + GreenMod(i32), + BlueMod(i32), +} + +impl ColorTransform { + pub fn xml_name(&self) -> &'static str { + match self { + ColorTransform::Tint(_) => "tint", + ColorTransform::Shade(_) => "shade", + ColorTransform::LumMod(_) => "lumMod", + ColorTransform::LumOff(_) => "lumOff", + ColorTransform::SatMod(_) => "satMod", + ColorTransform::SatOff(_) => "satOff", + ColorTransform::HueMod(_) => "hueMod", + ColorTransform::HueOff(_) => "hueOff", + ColorTransform::Alpha(_) => "alpha", + ColorTransform::AlphaMod(_) => "alphaMod", + ColorTransform::AlphaOff(_) => "alphaOff", + ColorTransform::Comp => "comp", + ColorTransform::Inv => "inv", + ColorTransform::Gray => "gray", + ColorTransform::RedMod(_) => "redMod", + ColorTransform::GreenMod(_) => "greenMod", + ColorTransform::BlueMod(_) => "blueMod", + } + } + pub fn value(&self) -> Option { + match *self { + ColorTransform::Tint(v) + | ColorTransform::Shade(v) + | ColorTransform::LumMod(v) + | ColorTransform::LumOff(v) + | ColorTransform::SatMod(v) + | ColorTransform::SatOff(v) + | ColorTransform::HueMod(v) + | ColorTransform::HueOff(v) + | ColorTransform::Alpha(v) + | ColorTransform::AlphaMod(v) + | ColorTransform::AlphaOff(v) + | ColorTransform::RedMod(v) + | ColorTransform::GreenMod(v) + | ColorTransform::BlueMod(v) => Some(v), + _ => None, + } + } + pub fn from_xml(name: &str, val: Option) -> Option { + let v = val.unwrap_or(0); + Some(match name { + "tint" => ColorTransform::Tint(v), + "shade" => ColorTransform::Shade(v), + "lumMod" => ColorTransform::LumMod(v), + "lumOff" => ColorTransform::LumOff(v), + "satMod" => ColorTransform::SatMod(v), + "satOff" => ColorTransform::SatOff(v), + "hueMod" => ColorTransform::HueMod(v), + "hueOff" => ColorTransform::HueOff(v), + "alpha" => ColorTransform::Alpha(v), + "alphaMod" => ColorTransform::AlphaMod(v), + "alphaOff" => ColorTransform::AlphaOff(v), + "comp" => ColorTransform::Comp, + "inv" => ColorTransform::Inv, + "gray" => ColorTransform::Gray, + "redMod" => ColorTransform::RedMod(v), + "greenMod" => ColorTransform::GreenMod(v), + "blueMod" => ColorTransform::BlueMod(v), + _ => return None, + }) + } +} + +/// Apply transforms to a base colour. +pub fn apply(base: Rgba, transforms: &[ColorTransform]) -> Rgba { + let mut c = base; + for t in transforms { + c = apply_one(c, *t); + } + c +} + +fn pct(v: i32) -> f64 { + v as f64 / 100_000.0 +} + +fn apply_one(c: Rgba, t: ColorTransform) -> Rgba { + match t { + // tint: towards white by (1 - value); shade: towards black by (1 - value). Office applies + // these in linear light, which keeps mid-tones from washing out. + ColorTransform::Tint(v) => { + let k = pct(v).clamp(0.0, 1.0); + map_linear(c, |x| x * k + (1.0 - k)) + } + ColorTransform::Shade(v) => { + let k = pct(v).clamp(0.0, 1.0); + map_linear(c, |x| x * k) + } + ColorTransform::LumMod(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s, l * pct(v), c.a) + } + ColorTransform::LumOff(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s, l + pct(v), c.a) + } + ColorTransform::SatMod(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s * pct(v), l, c.a) + } + ColorTransform::SatOff(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h, s + pct(v), l, c.a) + } + ColorTransform::HueMod(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h * pct(v), s, l, c.a) + } + ColorTransform::HueOff(v) => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h + v as f64 / 60_000.0, s, l, c.a) + } + ColorTransform::Alpha(v) => c.with_alpha((pct(v).clamp(0.0, 1.0) * 255.0).round() as u8), + ColorTransform::AlphaMod(v) => c.with_alpha((c.a as f64 * pct(v)).round().clamp(0.0, 255.0) as u8), + ColorTransform::AlphaOff(v) => c.with_alpha((c.a as f64 + pct(v) * 255.0).round().clamp(0.0, 255.0) as u8), + ColorTransform::Comp => { + let (h, s, l) = c.to_hsl(); + Rgba::from_hsl(h + 180.0, s, l, c.a) + } + ColorTransform::Inv => Rgba { r: 255 - c.r, g: 255 - c.g, b: 255 - c.b, a: c.a }, + ColorTransform::Gray => { + let y = (0.299 * c.r as f64 + 0.587 * c.g as f64 + 0.114 * c.b as f64).round().clamp(0.0, 255.0) as u8; + Rgba { r: y, g: y, b: y, a: c.a } + } + ColorTransform::RedMod(v) => Rgba { r: (c.r as f64 * pct(v)).round().clamp(0.0, 255.0) as u8, ..c }, + ColorTransform::GreenMod(v) => Rgba { g: (c.g as f64 * pct(v)).round().clamp(0.0, 255.0) as u8, ..c }, + ColorTransform::BlueMod(v) => Rgba { b: (c.b as f64 * pct(v)).round().clamp(0.0, 255.0) as u8, ..c }, + } +} + +fn to_lin(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} +fn from_lin(v: f64) -> f64 { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} +fn map_linear(c: Rgba, f: impl Fn(f64) -> f64) -> Rgba { + Rgba::from_f64(from_lin(f(to_lin(c.r))), from_lin(f(to_lin(c.g))), from_lin(f(to_lin(c.b))), c.a as f64 / 255.0) +} + +/// The colour-picker grid shown under each theme colour: lighter 80/60/40%, darker 25/50% +/// (for dark colours: lighter 90/75/50/25/10%). +pub fn theme_variants(c: Rgba) -> [(Vec, &'static str); 5] { + let (_, _, l) = c.to_hsl(); + if l < 0.2 { + [ + (vec![ColorTransform::LumMod(10000), ColorTransform::LumOff(90000)], "Lighter 90%"), + (vec![ColorTransform::LumMod(25000), ColorTransform::LumOff(75000)], "Lighter 75%"), + (vec![ColorTransform::LumMod(50000), ColorTransform::LumOff(50000)], "Lighter 50%"), + (vec![ColorTransform::LumMod(75000), ColorTransform::LumOff(25000)], "Lighter 25%"), + (vec![ColorTransform::LumMod(90000), ColorTransform::LumOff(10000)], "Lighter 10%"), + ] + } else if l > 0.8 { + [ + (vec![ColorTransform::LumMod(95000)], "Darker 5%"), + (vec![ColorTransform::LumMod(85000)], "Darker 15%"), + (vec![ColorTransform::LumMod(75000)], "Darker 25%"), + (vec![ColorTransform::LumMod(65000)], "Darker 35%"), + (vec![ColorTransform::LumMod(50000)], "Darker 50%"), + ] + } else { + [ + (vec![ColorTransform::LumMod(20000), ColorTransform::LumOff(80000)], "Lighter 80%"), + (vec![ColorTransform::LumMod(40000), ColorTransform::LumOff(60000)], "Lighter 60%"), + (vec![ColorTransform::LumMod(60000), ColorTransform::LumOff(40000)], "Lighter 40%"), + (vec![ColorTransform::LumMod(75000)], "Darker 25%"), + (vec![ColorTransform::LumMod(50000)], "Darker 50%"), + ] + } +} + +/// Standard colours row of the colour picker. +pub const STANDARD_COLORS: [(Rgba, &str); 10] = [ + (Rgba::rgb(0xC0, 0x00, 0x00), "Dark Red"), + (Rgba::rgb(0xFF, 0x00, 0x00), "Red"), + (Rgba::rgb(0xFF, 0xC0, 0x00), "Orange"), + (Rgba::rgb(0xFF, 0xFF, 0x00), "Yellow"), + (Rgba::rgb(0x92, 0xD0, 0x50), "Light Green"), + (Rgba::rgb(0x00, 0xB0, 0x50), "Green"), + (Rgba::rgb(0x00, 0xB0, 0xF0), "Light Blue"), + (Rgba::rgb(0x00, 0x70, 0xC0), "Blue"), + (Rgba::rgb(0x00, 0x20, 0x60), "Dark Blue"), + (Rgba::rgb(0x70, 0x30, 0xA0), "Purple"), +]; + +/// A few CSS-like preset colour names (`prstClr`). +pub fn preset(name: &str) -> Option { + let c = match name { + "black" => (0, 0, 0), + "white" => (255, 255, 255), + "red" => (255, 0, 0), + "green" => (0, 128, 0), + "lime" => (0, 255, 0), + "blue" => (0, 0, 255), + "yellow" => (255, 255, 0), + "cyan" | "aqua" => (0, 255, 255), + "magenta" | "fuchsia" => (255, 0, 255), + "gray" | "grey" => (128, 128, 128), + "silver" => (192, 192, 192), + "maroon" => (128, 0, 0), + "navy" => (0, 0, 128), + "olive" => (128, 128, 0), + "purple" => (128, 0, 128), + "teal" => (0, 128, 128), + "orange" => (255, 165, 0), + "pink" => (255, 192, 203), + "brown" => (165, 42, 42), + "gold" => (255, 215, 0), + "ltGray" | "lightGray" => (211, 211, 211), + "dkGray" | "darkGray" => (169, 169, 169), + _ => return None, + }; + Some(Rgba::rgb(c.0, c.1, c.2)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_roundtrip() { + assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82))); + assert_eq!(Rgba::rgb(0x15, 0x60, 0x82).hex(), "156082"); + assert_eq!(Rgba::from_hex("fff"), Some(Rgba::WHITE)); + assert_eq!(Rgba::from_hex("zz0000"), None); + assert_eq!(Rgba::from_hex("ééé"), None); + assert_eq!(Rgba::from_hex(""), None); + } + + #[test] + fn hsl_roundtrip() { + for c in [Rgba::rgb(10, 200, 30), Rgba::rgb(255, 0, 0), Rgba::rgb(128, 128, 128), Rgba::rgb(21, 96, 130)] { + let (h, s, l) = c.to_hsl(); + let d = Rgba::from_hsl(h, s, l, 255); + assert!( + (d.r as i32 - c.r as i32).abs() <= 1 && (d.g as i32 - c.g as i32).abs() <= 1 && (d.b as i32 - c.b as i32).abs() <= 1, + "{c:?} {d:?}" + ); + } + } + + #[test] + fn transforms() { + let c = Rgba::rgb(0x40, 0x80, 0xC0); + assert_eq!(apply(c, &[ColorTransform::Alpha(50000)]).a, 128); + let lighter = apply(c, &[ColorTransform::LumMod(20000), ColorTransform::LumOff(80000)]); + assert!(lighter.luminance() > c.luminance()); + let darker = apply(c, &[ColorTransform::LumMod(50000)]); + assert!(darker.luminance() < c.luminance()); + assert_eq!(apply(Rgba::WHITE, &[ColorTransform::Shade(0)]), Rgba::BLACK); + assert_eq!(apply(Rgba::BLACK, &[ColorTransform::Tint(0)]), Rgba::WHITE); + assert_eq!(apply(c, &[ColorTransform::Inv]), Rgba::rgb(0xBF, 0x7F, 0x3F)); + // hostile values never panic + let _ = apply(c, &[ColorTransform::LumMod(i32::MAX), ColorTransform::HueOff(i32::MIN), ColorTransform::AlphaMod(i32::MIN)]); + } + + #[test] + fn contrast_ratio() { + assert!((Rgba::BLACK.contrast(Rgba::WHITE) - 21.0).abs() < 0.01); + assert!((Rgba::WHITE.contrast(Rgba::WHITE) - 1.0).abs() < 1e-9); + } + + #[test] + fn scheme_slots() { + let mut s = ColorScheme { name: "t".into(), colors: [Rgba::BLACK; 12] }; + s.set(SchemeSlot::Accent3, Rgba::WHITE); + assert_eq!(s.get(SchemeSlot::Accent3), Rgba::WHITE); + assert_eq!(s.get(SchemeSlot::Tx1), Rgba::BLACK); + for slot in SchemeSlot::THEME { + assert_eq!(SchemeSlot::from_xml(slot.xml_name()), Some(slot)); + } + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml new file mode 100644 index 0000000..cff9086 --- /dev/null +++ b/crates/engine/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "slidecraft-engine" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft engine: sessions, undo history, selection, the command registry every frontend dispatches through, and pointer tools" + +[lints] +workspace = true + +[dependencies] +slidecraft-geom = { workspace = true } +slidecraft-color = { workspace = true } +slidecraft-model = { workspace = true } +slidecraft-fonts = { workspace = true } +slidecraft-text = { workspace = true } +slidecraft-render = { workspace = true } +slidecraft-format = { workspace = true } +slidecraft-pptx = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } +image = { workspace = true } diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs new file mode 100644 index 0000000..c19f92c --- /dev/null +++ b/crates/engine/src/cmd/animation.rs @@ -0,0 +1,294 @@ +//! Animations tab and Animation Pane. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "animation.set", + "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", + has_selection, + set + ), + cmd!( + "animation.add", + "Add Animation", + ["Animations", "Advanced Animation"], + None, + "{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}", + has_selection, + add + ), + cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove), + cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim), + cmd!( + "animation.timing", + "Timing", + ["Animations", "Timing"], + None, + "{index? (else selected shapes), start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, repeat?: n, rewind?: bool, trigger?: shape id | null}", + has_slide, + timing + ), + cmd!( + "animation.options", + "Effect Options", + ["Animations", "Animation"], + None, + "{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}", + has_slide, + options + ), + cmd!("animation.clear", "Remove All Animations", [], None, "{index?: slide}", has_slide, clear), + cmd!(query "animation.list", "Animation Gallery", [], None, "{} → [{id, label, class, options}]", always, list), + cmd!(query "animation.get", "Animations on Slide", [], None, "{slide?} → [animation]", has_slide, get), + ] +} + +fn class_of(s: Option<&str>) -> Option { + s.map(|c| match c { + "emphasis" | "emph" => AnimClass::Emphasis, + "exit" => AnimClass::Exit, + "path" | "motion" | "motionPath" => AnimClass::Path, + "media" => AnimClass::Media, + _ => AnimClass::Entrance, + }) +} + +fn start_of(s: &str) -> AnimStart { + match s { + "withPrevious" | "with" => AnimStart::WithPrevious, + "afterPrevious" | "after" => AnimStart::AfterPrevious, + _ => AnimStart::OnClick, + } +} + +fn build(p: &Value) -> Result { + let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?; + let class = class_of(str_param(p, "class")); + let info = match class { + Some(c) => animation_info(effect, c), + None => ANIMATIONS.iter().find(|a| a.0 == effect), + } + .ok_or_else(|| bad("animation", format!("unknown effect `{effect}`")))?; + let mut a = Animation { + class: info.2, + effect: info.0.to_string(), + option: str_param(p, "option").map(String::from).or_else(|| info.5.first().map(|o| o.to_string())).unwrap_or_default(), + duration_ms: p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(info.4), + delay_ms: p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32).unwrap_or(0), + preset_id: Some(info.3), + ..Default::default() + }; + if let Some(st) = str_param(p, "start") { + a.start = start_of(st); + } + if a.class == AnimClass::Path { + a.path = Some(default_path(&a.effect, &a.option)); + } + if a.effect == "spin" { + a.amount = Some(360.0); + } + if a.effect == "growShrink" { + a.amount = Some(1.5); + } + Ok(a) +} + +/// 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 { + "up" => (0.0, -0.25), + "right" => (0.25, 0.0), + "left" => (-0.25, 0.0), + _ => (0.0, 0.25), + }; + match effect { + "arcs" => format!("M 0 0 C {} {} {} {} {dx} {dy} E", dx * 0.1 + dy * 0.5, dy * 0.1 - dx * 0.5, dx * 0.9 + dy * 0.5, dy * 0.9 - dx * 0.5), + "turns" => format!("M 0 0 L {} {} C {} {} {dx} {} {dx} {dy} E", dx * 0.5, dy * 0.5, dx, dy * 0.5, dy * 0.75), + "shapes" => "M 0 0 C 0.1 0 0.1 0.15 0 0.15 C -0.1 0.15 -0.1 0 0 0 Z E".into(), + "loops" => "M 0 0 C 0.1 -0.1 0.2 0.1 0.1 0.1 C 0 0.1 0.1 -0.1 0.25 0 E".into(), + _ => format!("M 0 0 L {dx} {dy} E"), + } +} + +fn set(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let a = build(p)?; + 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); + // Replace each shape's animations, keeping their place in the sequence. + let mut out = vec![]; + let mut placed = vec![]; + for x in sl.animations.drain(..) { + if ids.contains(&x.shape) { + if !placed.contains(&x.shape) { + placed.push(x.shape); + out.push(Animation { shape: x.shape, start: x.start, ..a.clone() }); + } + } else { + out.push(x); + } + } + for id in &ids { + if !placed.contains(id) { + out.push(Animation { shape: *id, ..a.clone() }); + } + } + if a.effect == "none" { + out.retain(|x| !ids.contains(&x.shape)); + } + sl.animations = out; + Ok(json!({"count": sl.animations.len()})) + }) +} + +fn add(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let a = build(p)?; + 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() { + let mut x = Animation { shape: *id, ..a.clone() }; + if k > 0 { + x.start = AnimStart::WithPrevious; + } + sl.animations.push(x); + } + Ok(json!({"count": sl.animations.len()})) + }) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let index = usize_param(p, "index"); + let ids = if index.is_none() { targets(s, p)? } else { vec![] }; + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.remove", "no slide"))?); + match index { + Some(i) if i < sl.animations.len() => { + sl.animations.remove(i); + } + Some(_) => return Err(bad("animation.remove", "no such animation")), + None => sl.animations.retain(|a| !ids.contains(&a.shape)), + } + Ok(()) + })?; + ok() +} + +fn move_anim(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad("animation.move", "missing `index`"))?; + let to = usize_param(p, "to").ok_or_else(|| bad("animation.move", "missing `to`"))?; + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.move", "no slide"))?); + if i >= sl.animations.len() { + return Err(bad("animation.move", "no such animation")); + } + let a = sl.animations.remove(i); + let to = to.min(sl.animations.len()); + sl.animations.insert(to, a); + Ok(()) + })?; + ok() +} + +fn each_target(s: &mut Session, p: &Value, f: impl Fn(&mut Animation)) -> Result { + let index = usize_param(p, "index"); + let ids = if index.is_none() { targets(s, p)? } else { vec![] }; + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation", "no slide"))?); + let mut n = 0; + for (k, a) in sl.animations.iter_mut().enumerate() { + if index == Some(k) || (index.is_none() && ids.contains(&a.shape)) { + f(a); + n += 1; + } + } + if n == 0 { + return Err(bad("animation", "no animation to change (select an animated object or give `index`)")); + } + Ok(json!({"changed": n})) + }) +} + +fn timing(s: &mut Session, p: &Value) -> Result { + let start = str_param(p, "start").map(start_of); + let dur = p.get("duration").and_then(Value::as_u64).map(|v| v.min(600_000) as u32); + let delay = p.get("delay").and_then(Value::as_u64).map(|v| v.min(600_000) as u32); + let repeat = p.get("repeat").and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32); + let rewind = bool_param(p, "rewind"); + let trigger = p.get("trigger").map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).map(slidecraft_model::ShapeId)); + each_target(s, p, |a| { + if let Some(x) = start { + a.start = x; + } + if let Some(x) = dur { + a.duration_ms = x; + } + if let Some(x) = delay { + a.delay_ms = x; + } + if let Some(x) = repeat { + a.repeat = x; + } + if let Some(x) = rewind { + a.rewind = x; + } + if let Some(t) = trigger { + a.trigger = t; + } + }) +} + +fn options(s: &mut Session, p: &Value) -> Result { + let option = str_param(p, "option").map(String::from); + let build_mode = str_param(p, "textBuild").map(|b| match b { + "byParagraph" => TextBuild::ByParagraph, + "byWord" => TextBuild::ByWord, + "byLetter" => TextBuild::ByLetter, + _ => TextBuild::AsOne, + }); + each_target(s, p, |a| { + if let Some(o) = &option { + a.option = o.clone(); + if a.class == AnimClass::Path { + a.path = Some(default_path(&a.effect, o)); + } + } + if let Some(b) = build_mode { + a.text_build = b; + } + }) +} + +fn clear(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide); + s.edit(|doc, _| { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).animations.clear(); + } + Ok(()) + })?; + ok() +} + +fn list(_s: &mut Session, _p: &Value) -> Result { + Ok(Value::Array(ANIMATIONS.iter().map(|a| json!({"id": a.0, "label": a.1, "class": a.2.xml(), "options": a.5})).collect())) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "slide").unwrap_or(st.selection.slide); + let sl = st.doc.slides.get(i).ok_or_else(|| bad("animation.get", "no such slide"))?; + Ok(serde_json::to_value(&sl.animations).unwrap_or_default()) +} diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs new file mode 100644 index 0000000..49ee64b --- /dev/null +++ b/crates/engine/src/cmd/arrange.rs @@ -0,0 +1,286 @@ +//! Arrange: order, group, align, distribute, rotate and flip. + +use serde_json::{Value, json}; +use slidecraft_geom::{Point, Rect, Xfrm}; +use slidecraft_model::{Shape, ShapeId, ShapeKind}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!("arrange.bringToFront", "Bring to Front", ["Arrange"], Some("Cmd+Shift+]"), "{ids?}", has_selection, |s, p| order(s, p, 2)), + cmd!("arrange.bringForward", "Bring Forward", ["Arrange"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| order(s, p, 1)), + cmd!("arrange.sendBackward", "Send Backward", ["Arrange"], Some("Cmd+["), "{ids?}", has_selection, |s, p| order(s, p, -1)), + cmd!("arrange.sendToBack", "Send to Back", ["Arrange"], Some("Cmd+Shift+["), "{ids?}", has_selection, |s, p| order(s, p, -2)), + cmd!("arrange.reorder", "Reorder Objects", ["Arrange"], None, "{id, index: position in the stack (0 = back)}", has_slide, reorder), + cmd!("arrange.group", "Group", ["Arrange"], Some("Cmd+Alt+G"), "{ids?}", has_two, group), + cmd!("arrange.ungroup", "Ungroup", ["Arrange"], Some("Cmd+Alt+Shift+G"), "{ids?}", has_selection, ungroup), + cmd!("arrange.regroup", "Regroup", ["Arrange"], Some("Cmd+Alt+J"), "{}", has_doc, regroup), + cmd!( + "arrange.align", + "Align", + ["Arrange", "Align or Distribute"], + None, + "{edge: left|center|right|top|middle|bottom, to?: slide|selection (default: slide for one object), ids?}", + has_selection, + align + ), + cmd!( + "arrange.distribute", + "Distribute", + ["Arrange", "Align or Distribute"], + None, + "{dir: horizontal|vertical, to?: slide|selection, ids?}", + has_selection, + distribute + ), + cmd!("arrange.rotateLeft", "Rotate Left 90°", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s + .execute("shape.rotate", &with_param(p, "by", json!(-90.0)))), + cmd!("arrange.rotateRight", "Rotate Right 90°", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s + .execute("shape.rotate", &with_param(p, "by", json!(90.0)))), + cmd!("arrange.flipHorizontal", "Flip Horizontal", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s + .execute("shape.flip", &with_param(p, "axis", json!("horizontal")))), + cmd!("arrange.flipVertical", "Flip Vertical", ["Arrange", "Rotate or Flip"], None, "{ids?}", has_selection, |s, p| s + .execute("shape.flip", &with_param(p, "axis", json!("vertical")))), + cmd!("arrange.nudge", "Nudge", [], None, "{dx, dy, ids?} (arrow keys: 6 pt, with Alt 1 pt)", has_selection, |s, p| s + .execute("shape.move", p)), + ] +} + +fn order(s: &mut Session, p: &Value, how: i32) -> Result { + let ids = targets(s, p)?; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange", "no slide"))?; + match how { + 2 => { + let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); + list.extend(rest); + list.append(&mut moved); + } + -2 => { + let (mut moved, rest): (Vec, Vec) = list.drain(..).partition(|x| ids.contains(&x.id)); + moved.extend(rest); + *list = moved; + } + 1 => { + for i in (0..list.len().saturating_sub(1)).rev() { + let (a, b) = (list.get(i).map(|x| ids.contains(&x.id)), list.get(i + 1).map(|x| ids.contains(&x.id))); + if a == Some(true) && b == Some(false) { + list.swap(i, i + 1); + } + } + } + _ => { + for i in 1..list.len() { + let (a, b) = (list.get(i - 1).map(|x| ids.contains(&x.id)), list.get(i).map(|x| ids.contains(&x.id))); + if a == Some(false) && b == Some(true) { + list.swap(i - 1, i); + } + } + } + } + Ok(()) + })?; + ok() +} + +fn reorder(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id").ok_or_else(|| bad("arrange.reorder", "missing `id`"))?; + let to = usize_param(p, "index").ok_or_else(|| bad("arrange.reorder", "missing `index`"))?; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.reorder", "no slide"))?; + let i = list.iter().position(|x| x.id == id).ok_or_else(|| bad("arrange.reorder", "not a top-level shape"))?; + let sh = list.remove(i); + list.insert(to.min(list.len()), sh); + Ok(()) + })?; + ok() +} + +/// Bounds of shapes (rotation included). +fn union_bounds(xs: &[Xfrm]) -> Option { + let mut it = xs.iter().map(Xfrm::bounds); + let first = it.next()?; + Some(it.fold(first, |a, b| a.union(b))) +} + +fn group(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|doc, sel| { + let current = super::current_shapes(doc, sel).cloned().unwrap_or_default(); + let mut members: Vec = current.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); + if members.len() < 2 { + return Err(bad("arrange.group", "select two or more objects on the slide")); + } + for m in &mut members { + if m.xfrm.is_none() { + m.xfrm = Some(xfrm_of(doc, sel, m)); + } + // Placeholders lose their link when grouped (PowerPoint keeps them out of groups). + m.ph = None; + } + let xs: Vec = members.iter().filter_map(|m| m.xfrm).collect(); + let b = union_bounds(&xs).ok_or_else(|| bad("arrange.group", "nothing to group"))?; + let gx = Xfrm::new(b.x0, b.y0, b.width(), b.height()); + let id = ShapeId(doc.alloc_id()); + let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0); + let g = Shape { + id, + name: format!("Group {}", n + 1), + xfrm: Some(gx), + kind: ShapeKind::Group { children: members, child: gx }, + ..Default::default() + }; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.group", "no slide"))?; + let at = list.iter().rposition(|x| ids.contains(&x.id)).unwrap_or(list.len()); + list.insert(at + 1, g); + list.retain(|x| !ids.contains(&x.id)); + sel.shapes = vec![id]; + Ok(json!({"id": id})) + }) +} + +/// A child's xfrm in slide space, given its group. +pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm { + let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 }; + let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 }; + let a = slidecraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h)); + let center = a * Point::new(c.x + c.w / 2.0, c.y + c.h / 2.0); + let (w, h) = (c.w * sx.abs(), c.h * sy.abs()); + let mut rot = c.rot + group.rot; + let (mut fh, mut fv) = (c.flip_h, c.flip_v); + if group.flip_h { + fh = !fh; + rot = -c.rot + group.rot; + } + if group.flip_v { + fv = !fv; + rot = -rot + 2.0 * group.rot; + } + Xfrm { x: center.x - w / 2.0, y: center.y - h / 2.0, w, h, rot: ((rot % 360.0) + 360.0) % 360.0, flip_h: fh, flip_v: fv } +} + +fn ungroup(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("arrange.ungroup", "no slide"))?; + let mut out = Vec::with_capacity(list.len()); + let mut new_sel = vec![]; + for sh in list.drain(..) { + if ids.contains(&sh.id) + && let (ShapeKind::Group { children, child }, Some(gx)) = (&sh.kind, sh.xfrm) + { + for c in children { + let mut c = c.clone(); + if let Some(cx) = c.xfrm { + c.xfrm = Some(child_to_parent(&gx, child, &cx)); + } + // Nested groups scale too: their child space stays, their box is mapped. + new_sel.push(c.id); + out.push(c); + } + continue; + } + out.push(sh); + } + *list = out; + sel.shapes = new_sel.clone(); + Ok(json!({"ids": new_sel})) + }) +} + +fn regroup(s: &mut Session, _p: &Value) -> Result { + // Regroup the most recently ungrouped objects (from the undo history). + let st = s.doc()?; + let last = st.history.undo.iter().rev().find(|e| e.label == "Ungroup").map(|e| e.selection.shapes.clone()); + match last { + Some(prev_group) => { + // The ungroup's selection held the group id; its children are the current selection when nothing else changed. + let _ = prev_group; + let ids = s.doc()?.selection.shapes.clone(); + if ids.len() >= 2 { s.execute("arrange.group", &json!({"ids": ids})) } else { Err(bad("arrange.regroup", "nothing to regroup")) } + } + None => Err(bad("arrange.regroup", "nothing to regroup")), + } +} + +fn align(s: &mut Session, p: &Value) -> Result { + let edge = str_param(p, "edge").unwrap_or("left").to_string(); + let ids = targets(s, p)?; + let st = s.doc()?; + let size = st.doc.slide_size; + let boxes: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|id| st.shape(*id).map(|x| (*id, xfrm_of(&st.doc, &st.selection, x)))).collect(); + let to_slide = match str_param(p, "to") { + Some("slide") => true, + Some(_) => false, + None => boxes.len() < 2, + }; + let reference = if to_slide { + Rect::new(0.0, 0.0, size.width, size.height) + } else { + union_bounds(&boxes.iter().map(|b| b.1).collect::>()).unwrap_or_default() + }; + edit_shapes(s, p, "arrange.align", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + let b = x.bounds(); + let (dx, dy) = match edge.as_str() { + "left" => (reference.x0 - b.x0, 0.0), + "center" => (reference.center().x - b.center().x, 0.0), + "right" => (reference.x1 - b.x1, 0.0), + "top" => (0.0, reference.y0 - b.y0), + "middle" => (0.0, reference.center().y - b.center().y), + "bottom" => (0.0, reference.y1 - b.y1), + _ => (0.0, 0.0), + }; + x.x += dx; + x.y += dy; + } + Ok(()) + }) +} + +fn distribute(s: &mut Session, p: &Value) -> Result { + let horiz = !str_param(p, "dir").unwrap_or("horizontal").starts_with('v'); + let ids = targets(s, p)?; + let st = s.doc()?; + let size = st.doc.slide_size; + let mut boxes: Vec<(ShapeId, Rect)> = + ids.iter().filter_map(|id| st.shape(*id).map(|x| (*id, xfrm_of(&st.doc, &st.selection, x).bounds()))).collect(); + let to_slide = str_param(p, "to") == Some("slide") || boxes.len() < 3; + if boxes.is_empty() { + return ok(); + } + boxes.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); + let total: f64 = boxes.iter().map(|b| if horiz { b.1.width() } else { b.1.height() }).sum(); + let (start, end) = if to_slide { + if horiz { (0.0, size.width) } else { (0.0, size.height) } + } else { + let f = boxes.first().map(|b| b.1).unwrap_or_default(); + let l = boxes.last().map(|b| b.1).unwrap_or_default(); + if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) } + }; + let n = boxes.len(); + let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 }; + let mut targets_pos: Vec<(ShapeId, f64)> = vec![]; + let mut cur = start; + if n == 1 { + let b = boxes.first().map(|b| b.1).unwrap_or_default(); + cur = start + ((end - start) - if horiz { b.width() } else { b.height() }) / 2.0; + } + for (id, b) in &boxes { + let d = cur - if horiz { b.x0 } else { b.y0 }; + targets_pos.push((*id, d)); + cur += (if horiz { b.width() } else { b.height() }) + gap; + } + edit_shapes(s, p, "arrange.distribute", |sh| { + if let (Some(x), Some((_, d))) = (sh.xfrm.as_mut(), targets_pos.iter().find(|(i, _)| *i == sh.id)) { + if horiz { + x.x += d; + } else { + x.y += d; + } + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs new file mode 100644 index 0000000..5906dc9 --- /dev/null +++ b/crates/engine/src/cmd/design.rs @@ -0,0 +1,561 @@ +//! Design tab and masters: themes, colours, fonts, backgrounds, slide size, header & footer, +//! Slide Master view. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_geom::Size; +use slidecraft_model::text::{Run, RunKind, TextBody}; +use slidecraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builtin_themes}; +use slidecraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults}; + +use super::*; +use crate::{Result, Session, Target}; + +pub fn specs() -> Vec { + vec![ + cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), + cmd!(query "design.themes", "Theme Gallery", [], None, "{} → themes, colour schemes and font schemes", always, themes), + cmd!("design.colors", "Colors", ["Design", "Variants"], None, "{name} | {colors: {accent1: #RRGGBB, …}}", has_doc, colors), + cmd!("design.fonts", "Fonts", ["Design", "Variants"], None, "{name} | {major, minor}", has_doc, fonts), + cmd!( + "design.background", + "Format Background", + ["Design", "Customize"], + None, + "{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}", + has_slide, + background + ), + cmd!("design.hideBackgroundGraphics", "Hide Background Graphics", ["Format Background"], None, "{hide?: bool}", has_slide, hide_bg_graphics), + cmd!( + "design.slideSize", + "Slide Size", + ["Design", "Customize"], + None, + "{preset?: widescreen|standard|… | w, h: pt, scale?: maximize|ensureFit|none}", + has_doc, + slide_size + ), + cmd!( + "design.headerFooter", + "Header and Footer…", + ["Insert", "Text"], + None, + "{date?: bool, dateText?: fixed text, slideNumber?: bool, footer?: bool, footerText?, hideOnTitle?: bool, all?: bool (default true)}", + has_doc, + header_footer + ), + cmd!(noundo "view.slideMaster", "Slide Master", ["View", "Master Views"], None, "{master?: index, layout?: index}", has_doc, master_view), + cmd!(noundo "view.closeMaster", "Close Master View", ["Slide Master", "Close"], None, "{}", has_doc, close_master), + cmd!("master.insertLayout", "Insert Layout", ["Slide Master", "Edit Master"], None, "{name?}", has_doc, insert_layout), + cmd!("master.renameLayout", "Rename Layout", ["Slide Master", "Edit Master"], None, "{name, master?, layout?}", has_doc, rename_layout), + cmd!("master.deleteLayout", "Delete Layout", ["Slide Master", "Edit Master"], None, "{master?, layout?}", has_doc, delete_layout), + cmd!( + "master.insertPlaceholder", + "Insert Placeholder", + ["Slide Master", "Master Layout"], + None, + "{kind: content|text|picture|chart|table|media, rect?: [x,y,w,h]}", + has_doc, + insert_placeholder + ), + ] +} + +fn theme(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("design.theme", "missing `name`"))?; + let t = + builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad("design.theme", format!("no theme `{name}`")))?; + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = doc.masters.get_mut(mi).ok_or_else(|| bad("design.theme", "no master"))?; + let m = Arc::make_mut(m); + m.theme = t.clone(); + m.name = t.name.clone(); + m.color_map = vec![]; + Ok(()) + })?; + Ok(json!({"theme": name})) +} + +fn master_index(doc: &Presentation, sel: &crate::Selection) -> usize { + match sel.target { + Target::Master { master } | Target::Layout { master, .. } => master, + Target::Slides => doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0), + } +} + +fn themes(_s: &mut Session, _p: &Value) -> Result { + Ok(json!({ + "themes": builtin_themes().iter().map(|t| json!({"name": t.name, "major": t.fonts.major.latin, "minor": t.fonts.minor.latin, "colors": t.colors.colors.iter().map(|c| format!("#{}", c.hex())).collect::>()})).collect::>(), + "colorSchemes": builtin_color_schemes().iter().map(|c| c.name.clone()).collect::>(), + "fontSchemes": builtin_font_schemes().iter().map(|f| json!({"name": f.name, "major": f.major.latin, "minor": f.minor.latin})).collect::>(), + })) +} + +fn colors(s: &mut Session, p: &Value) -> Result { + let scheme = if let Some(name) = str_param(p, "name") { + Some( + builtin_color_schemes() + .into_iter() + .find(|c| c.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("design.colors", format!("no colour scheme `{name}`")))?, + ) + } else { + None + }; + let custom = p.get("colors").and_then(Value::as_object).cloned(); + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); + if let Some(sc) = &scheme { + m.theme.colors = sc.clone(); + } + if let Some(c) = &custom { + for (k, v) in c { + if let (Some(slot), Some(rgb)) = (slidecraft_color::SchemeSlot::from_xml(k), v.as_str().and_then(slidecraft_color::Rgba::from_hex)) { + m.theme.colors.set(slot, rgb); + } + } + m.theme.colors.name = "Custom".into(); + } + Ok(()) + })?; + ok() +} + +fn fonts(s: &mut Session, p: &Value) -> Result { + let scheme = if let Some(name) = str_param(p, "name") { + builtin_font_schemes() + .into_iter() + .find(|f| f.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("design.fonts", format!("no font scheme `{name}`")))? + } else { + let major = str_param(p, "major").ok_or_else(|| bad("design.fonts", "give `name`, or `major` and `minor`"))?; + let minor = str_param(p, "minor").unwrap_or(major); + slidecraft_model::theme::FontScheme { + name: "Custom".into(), + major: slidecraft_model::theme::FontSet { latin: major.into(), ..Default::default() }, + minor: slidecraft_model::theme::FontSet { latin: minor.into(), ..Default::default() }, + } + }; + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = scheme; + Ok(()) + })?; + ok() +} + +fn background(s: &mut Session, p: &Value) -> Result { + let bg: Option = if bool_or(p, "reset", false) { + None + } else if let Some(c) = color_param(p, "color") { + Some(Background::Fill { fill: Fill::solid(c) }) + } else if let Some(i) = usize_param(p, "style") { + // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). + let slots = [ + slidecraft_color::SchemeSlot::Bg1, + slidecraft_color::SchemeSlot::Bg2, + slidecraft_color::SchemeSlot::Tx2, + slidecraft_color::SchemeSlot::Tx1, + ]; + let i = i.clamp(1, 12) - 1; + Some(Background::Ref { + idx: 1001 + (i / 4) as u32, + color: slidecraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(slidecraft_color::SchemeSlot::Bg1)), + }) + } else if let Some(g) = p.get("gradient") { + let stops: Vec = g + .get("stops") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| Some(slidecraft_model::style::GradientStop { pos: s.get(0)?.as_f64()?, color: color_value(s.get(1)?)? })) + .collect() + }) + .unwrap_or_default(); + if stops.len() < 2 { + return Err(bad("design.background", "gradient needs two or more `stops`")); + } + Some(Background::Fill { + fill: Fill::Gradient(slidecraft_model::style::Gradient { + stops, + shape: slidecraft_model::style::GradientShape::Linear { + angle: g.get("angle").and_then(Value::as_f64).unwrap_or(90.0), + scaled: false, + }, + rotate_with_shape: false, + }), + }) + } else if p.get("picture").is_some() { + let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; + let ct = super::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + Some(Background::Fill { fill: Fill::Picture(slidecraft_model::style::PictureFill { media, ..Default::default() }) }) + } else { + return Err(bad("design.background", "give `color`, `gradient`, `picture`, `style` or `reset`")); + }; + let all = bool_or(p, "all", false); + let index = usize_param(p, "index"); + s.edit(|doc, sel| { + match sel.target { + Target::Master { master } => { + if let Some(m) = doc.masters.get_mut(master) { + Arc::make_mut(m).background = bg.clone(); + } + } + Target::Layout { master, layout } => { + if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { + l.background = bg.clone(); + } + } + Target::Slides => { + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).background = bg.clone(); + } + } + } + } + Ok(()) + })?; + ok() +} + +fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + if let Some(sl) = doc.slides.get_mut(sel.slide) { + let sl = Arc::make_mut(sl); + sl.show_master_shapes = !bool_param(p, "hide").unwrap_or(sl.show_master_shapes); + } + Ok(()) + })?; + ok() +} + +fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + x.x = x.x * sx + ox; + x.y = x.y * sy + oy; + x.w *= sx; + x.h *= sy; + } + if let Some(t) = sh.text.as_mut() { + slidecraft_model::edit::format_all(t, &|r| { + if let Some(sz) = r.size.as_mut() { + *sz = (*sz * k).max(1.0); + } + }); + } + } +} + +fn slide_size(s: &mut Session, p: &Value) -> Result { + let size = if let Some(pre) = str_param(p, "preset") { + let k = pre.to_ascii_lowercase(); + let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k)); + let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?; + Size::new(*w, *h) + } else { + Size::new( + f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0), + f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0), + ) + }; + let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); + s.edit(|doc, _| { + let old = doc.slide_size; + let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + let (sx, sy, k, ox, oy) = match mode.as_str() { + "none" => (1.0, 1.0, 1.0, 0.0, 0.0), + "maximize" => { + let k = sx.max(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + _ => { + let k = sx.min(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + }; + // Masters and layouts stretch to the new size; slides scale by mode. + let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + for m in &mut doc.masters { + let m = Arc::make_mut(m); + scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); + for l in &mut m.layouts { + scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); + } + } + for sl in &mut doc.slides { + scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); + } + doc.slide_size = size; + Ok(()) + })?; + Ok(json!({"w": size.width, "h": size.height})) +} + +fn header_footer(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + let hf = &mut doc.header_footer; + if let Some(v) = bool_param(p, "date") { + hf.date = v; + } + if let Some(v) = str_param(p, "dateText") { + hf.date_text = v.into(); + } + if let Some(v) = bool_param(p, "slideNumber") { + hf.slide_number = v; + } + if let Some(v) = bool_param(p, "footer") { + hf.footer = v; + } + if let Some(v) = str_param(p, "footerText") { + hf.footer_text = v.into(); + hf.footer = hf.footer || !v.is_empty(); + } + if let Some(v) = bool_param(p, "hideOnTitle") { + hf.hide_on_title = v; + } + let hf = hf.clone(); + let all = bool_or(p, "all", true); + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; + for i in list { + let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; + let lay = doc.layout(layout).map(|(_, l)| l.clone()); + let is_title = lay.as_ref().is_some_and(|l| l.kind == slidecraft_model::LayoutType::Title); + let want = |k: PhType| { + !(hf.hide_on_title && is_title) + && match k { + PhType::Date => hf.date, + PhType::SlideNum => hf.slide_number, + PhType::Footer => hf.footer, + _ => false, + } + }; + let mut new_ids = vec![]; + for _ in 0..3 { + new_ids.push(ShapeId(doc.alloc_id())); + } + let Some(sl) = doc.slides.get_mut(i) else { continue }; + let sl = Arc::make_mut(sl); + for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { + let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); + match (has, want(k)) { + (Some(pos), false) => { + sl.shapes.remove(pos); + } + (None, true) => { + let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); + let Some(lp) = lp else { continue }; + let body = match k { + PhType::SlideNum => TextBody { + paragraphs: vec![slidecraft_model::Paragraph { + runs: vec![Run { + text: "‹#›".into(), + props: Default::default(), + kind: RunKind::Field { field: "slidenum".into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date if hf.date_text.is_empty() => TextBody { + paragraphs: vec![slidecraft_model::Paragraph { + runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date => TextBody::from_text(&hf.date_text), + _ => TextBody::from_text(&hf.footer_text), + }; + sl.shapes.push(Shape { + id: new_ids.get(n).copied().unwrap_or_default(), + name: lp.name.clone(), + ph: lp.ph.clone(), + text: Some(body), + ..Default::default() + }); + } + (Some(pos), true) if k == PhType::Footer => { + if let Some(sh) = sl.shapes.get_mut(pos) { + sh.text = Some(TextBody::from_text(&hf.footer_text)); + } + } + _ => {} + } + } + } + Ok(()) + })?; + ok() +} + +/// Today's date as M/D/YYYY (no clock crate: computed from the system time). +pub fn today() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let days = (secs / 86_400) as i64; + let (y, m, d) = civil(days); + format!("{m}/{d}/{y}") + } + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). +pub fn civil(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +fn master_view(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); + let layout = usize_param(p, "layout") + .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))); + let target = match layout { + Some(l) => Target::Layout { master: mi, layout: l }, + None => Target::Master { master: mi }, + }; + s.select(|_, sel| { + sel.target = target; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(serde_json::to_value(target).unwrap_or_default()) +} + +fn close_master(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + sel.target = Target::Slides; + sel.shapes.clear(); + sel.text = None; + })?; + ok() +} + +fn insert_layout(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("Custom Layout").to_string(); + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let id = slidecraft_model::LayoutId(doc.alloc_id()); + let tid = ShapeId(doc.alloc_id()); + let size = doc.slide_size; + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); + let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { + t.id = tid; + t.text = Some(TextBody::default()); + t + }); + let _ = size; + let at = match sel.target { + Target::Layout { layout, .. } => layout + 1, + _ => m.layouts.len(), + }; + m.layouts.insert( + at.min(m.layouts.len()), + slidecraft_model::Layout { + id, + name, + kind: slidecraft_model::LayoutType::Custom, + shapes: title.into_iter().collect(), + background: None, + show_master_shapes: true, + preserve: true, + raw_ext: None, + }, + ); + sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; + Ok(json!({"id": id})) + }) +} + +fn layout_target(s: &Session, p: &Value) -> Result<(usize, usize)> { + let st = s.doc()?; + let (m, l) = match st.selection.target { + Target::Layout { master, layout } => (master, layout), + _ => (master_index(&st.doc, &st.selection), 0), + }; + Ok((usize_param(p, "master").unwrap_or(m), usize_param(p, "layout").unwrap_or(l))) +} + +fn rename_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p)?; + let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); + s.edit(|doc, _| { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; + l.name = name; + Ok(()) + })?; + ok() +} + +fn delete_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p)?; + s.edit(|doc, sel| { + let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; + if doc.slides.iter().any(|x| x.layout == id) { + return Err(bad("master.deleteLayout", "slides use this layout")); + } + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); + if m.layouts.len() <= 1 { + return Err(bad("master.deleteLayout", "a master needs at least one layout")); + } + m.layouts.remove(li); + sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; + Ok(()) + })?; + ok() +} + +fn insert_placeholder(s: &mut Session, p: &Value) -> Result { + let kind = match str_param(p, "kind").unwrap_or("content") { + "text" => PhType::Body, + "picture" => PhType::Picture, + "chart" => PhType::Chart, + "table" => PhType::Table, + "media" => PhType::Media, + _ => PhType::Obj, + }; + if !matches!(s.doc()?.selection.target, Target::Layout { .. }) { + return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view")); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(slidecraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); + let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; + let shape = Shape { + xfrm: Some(rect), + ph: Some(slidecraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(TextBody::default()), + ..Default::default() + }; + let id = super::insert::add_shape(s, shape, true)?; + Ok(json!({"id": id})) +} + +#[cfg(test)] +mod tests { + #[test] + fn civil_dates() { + assert_eq!(super::civil(0), (1970, 1, 1)); + assert_eq!(super::civil(20_734), (2026, 10, 8)); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs new file mode 100644 index 0000000..31666b5 --- /dev/null +++ b/crates/engine/src/cmd/edit.rs @@ -0,0 +1,380 @@ +//! Edit menu: undo/redo, clipboard, duplicate, delete, select all. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_model::{MediaId, Shape, ShapeId, Slide, SlideId}; + +use super::*; +use crate::{HistoryEntry, Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), + cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{}", can_redo, redo), + cmd!("edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{scope?: slides}", has_doc, cut), + cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{scope?: slides} → {text}", has_doc, copy), + cmd!("edit.paste", "Paste", ["Home", "Clipboard"], Some("Cmd+V"), "{text?: plain text to paste, scope?: slides}", has_doc, paste), + cmd!("edit.pasteText", "Paste and Match Formatting", ["Edit"], Some("Cmd+Shift+V"), "{text?}", has_doc, paste_text), + cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{ids?}", has_selection, duplicate), + cmd!("edit.delete", "Delete", ["Edit"], Some("Delete"), "{ids?, scope?: slides}", has_doc, delete), + cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_slide, select_all), + cmd!(noundo "edit.deselect", "Deselect", [], Some("Escape"), "{}", has_doc, deselect), + cmd!(noundo "edit.select", "Select Objects", [], None, "{ids: [id], add?: bool, toggle?: bool}", has_slide, select), + ] +} + +fn restore(s: &mut Session, redo: bool) -> Result { + let st = s.doc_mut()?; + let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) }; + let Some(e) = from.pop() else { return ok() }; + to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); + st.doc = e.doc; + st.selection = e.selection; + st.interaction = None; + st.revision += 1; + Ok(json!({"label": e.label})) +} + +fn undo(s: &mut Session, _p: &Value) -> Result { + restore(s, false) +} +fn redo(s: &mut Session, _p: &Value) -> Result { + restore(s, true) +} + +fn slides_scope(s: &Session, p: &Value) -> bool { + str_param(p, "scope") == Some("slides") + || s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty()) +} + +fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { + slidecraft_model::walk(shapes, &mut |s, _| { + match &s.kind { + slidecraft_model::ShapeKind::Picture { fill } => out.push(fill.media), + slidecraft_model::ShapeKind::Media(m) => { + out.push(m.media); + if let Some(p) = m.poster { + out.push(p); + } + } + _ => {} + } + if let Some(slidecraft_model::Fill::Picture(pf)) = &s.fill { + out.push(pf.media); + } + }); +} + +fn copy_inner(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + // Text selection. + if let Some(t) = &st.selection.text { + if t.is_range() { + let body = super::text::body_of(st, t).cloned().unwrap_or_default(); + let (a, b) = t.ordered(); + let plain = slidecraft_model::edit::text_range(&body, a, b); + let sliced = slidecraft_model::edit::slice(&body, a, b); + s.clipboard = crate::Clipboard { text: Some(sliced), plain: plain.clone(), ..Default::default() }; + return Ok(plain); + } + return Ok(String::new()); + } + if slides_scope(s, p) { + let ids: Vec = + if st.selection.slides.is_empty() { st.current_slide().map(|x| vec![x.id]).unwrap_or_default() } else { st.selection.slides.clone() }; + let slides: Vec = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect(); + let mut mids = vec![]; + for sl in &slides { + media_of_shapes(&sl.shapes, &mut mids); + } + let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); + let plain = slides.iter().map(|x| x.title()).collect::>().join("\n"); + s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() }; + return Ok(plain); + } + let shapes: Vec = st + .selection + .shapes + .iter() + .filter_map(|id| { + st.shape(*id).cloned().map(|mut sh| { + if sh.xfrm.is_none() { + sh.xfrm = Some(xfrm_of(&st.doc, &st.selection, &sh)); + } + sh + }) + }) + .collect(); + let mut mids = vec![]; + media_of_shapes(&shapes, &mut mids); + let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); + let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::>().join("\n"); + s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), ..Default::default() }; + Ok(plain) +} + +fn copy(s: &mut Session, p: &Value) -> Result { + let t = copy_inner(s, p)?; + Ok(json!({"text": t})) +} + +fn cut(s: &mut Session, p: &Value) -> Result { + let t = copy_inner(s, p)?; + delete(s, p)?; + Ok(json!({"text": t})) +} + +/// Give pasted shapes fresh ids (groups included) and map connector ends. +pub(crate) fn reid(doc: &mut slidecraft_model::Presentation, shapes: &mut [Shape]) { + fn rec(doc: &mut slidecraft_model::Presentation, shapes: &mut [Shape], depth: usize) { + if depth > 64 { + return; + } + for s in shapes { + s.id = ShapeId(doc.alloc_id()); + if let Some(ch) = s.children_mut() { + rec(doc, ch, depth + 1); + } + } + } + rec(doc, shapes, 0); +} + +fn bring_media(doc: &mut slidecraft_model::Presentation, media: &[slidecraft_model::MediaItem]) { + for m in media { + if doc.media(m.id).is_none_or(|x| x.data != m.data) { + if doc.media(m.id).is_none() { + doc.media.push(m.clone()); + } else { + // Id clash with different bytes: unusual (other deck); keep ours, add theirs as new. + let id = doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()); + let _ = id; + } + } + } +} + +fn paste(s: &mut Session, p: &Value) -> Result { + // External plain text (from the system clipboard) wins when given and differs from ours. + if let Some(t) = str_param(p, "text") + && t != s.clipboard.plain + { + return paste_plain(s, t); + } + let editing = s.doc()?.selection.text.is_some(); + if editing { + if let Some(body) = s.clipboard.text.clone() { + return super::text::paste_body(s, &body); + } + let plain = s.clipboard.plain.clone(); + return paste_plain(s, &plain); + } + let clip = s.clipboard.clone(); + if !clip.slides.is_empty() { + let at = s.doc()?.selection.slide + 1; + return s.edit(|doc, sel| { + bring_media(doc, &clip.media); + let mut new_ids = vec![]; + for (k, sl) in clip.slides.iter().enumerate() { + let mut sl = sl.clone(); + sl.id = SlideId(doc.alloc_id()); + reid(doc, &mut sl.shapes); + if doc.layout(sl.layout).is_none() { + sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + } + new_ids.push(sl.id); + let i = (at + k).min(doc.slides.len()); + doc.slides.insert(i, Arc::new(sl)); + } + sel.slide = (at + new_ids.len()).saturating_sub(1).min(doc.slides.len().saturating_sub(1)); + sel.slides = new_ids.clone(); + sel.shapes.clear(); + Ok(json!({"slides": new_ids.len()})) + }); + } + if !clip.shapes.is_empty() { + s.clipboard.pastes += 1; + let off = s.prefs.duplicate_offset * s.clipboard.pastes as f64; + // Pasting on another slide keeps the position; on the same slide it offsets. + let same_slide = s.doc().ok().is_some_and(|d| clip.shapes.iter().any(|c| d.shape(c.id).is_some())); + let off = if same_slide { off } else { 0.0 }; + return s.edit(|doc, sel| { + bring_media(doc, &clip.media); + let mut shapes = clip.shapes.clone(); + reid(doc, &mut shapes); + for sh in &mut shapes { + if let Some(x) = sh.xfrm.as_mut() { + x.x += off; + x.y += off; + } + // A pasted placeholder becomes a regular shape keeping its look. + if sh.ph.is_some() && sel.target == crate::Target::Slides { + sh.ph = None; + } + } + let ids: Vec = shapes.iter().map(|x| x.id).collect(); + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.paste", "no slide"))?; + list.extend(shapes); + sel.shapes = ids.clone(); + sel.text = None; + Ok(json!({"ids": ids})) + }); + } + if !clip.plain.is_empty() { + let t = clip.plain.clone(); + return paste_plain(s, &t); + } + ok() +} + +fn paste_plain(s: &mut Session, text: &str) -> Result { + if s.doc()?.selection.text.is_some() { + return super::text::insert_text(s, text, None); + } + // No text box being edited: a new text box with the text. + let st = s.doc()?; + let size = st.doc.slide_size; + let rect = [size.width * 0.25, size.height * 0.4, size.width * 0.5, 40.0]; + s.execute("insert.textBox", &json!({"rect": rect, "text": text})) +} + +fn paste_text(s: &mut Session, p: &Value) -> Result { + let t = str_param(p, "text").map(String::from).unwrap_or_else(|| s.clipboard.plain.clone()); + paste_plain(s, &t) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + let ids = targets(s, p)?; + let off = s.prefs.duplicate_offset; + s.edit(|doc, sel| { + let list = super::current_shapes(doc, sel).cloned().unwrap_or_default(); + let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); + let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + reid(doc, &mut copies); + for (c, b) in copies.iter_mut().zip(boxes) { + let mut x = b; + x.x += off; + x.y += off; + c.xfrm = Some(x); + if sel.target == crate::Target::Slides { + c.ph = None; + } + if !c.name.is_empty() { + c.name = format!("{} copy", c.name.trim_end_matches(" copy")); + } + } + let new_ids: Vec = copies.iter().map(|c| c.id).collect(); + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?; + shapes.extend(copies); + sel.shapes = new_ids.clone(); + Ok(json!({"ids": new_ids})) + }) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + if s.doc()?.selection.text.is_some() && ids_param(p, "ids").is_none() { + return super::text::delete_dir(s, "forward"); + } + if slides_scope(s, p) && ids_param(p, "ids").is_none() { + return s.execute("slide.delete", &json!({})); + } + let ids = targets(s, p)?; + if ids.is_empty() { + return ok(); + } + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?; + let n = remove_shapes(shapes, &ids); + // Animations of deleted shapes go too. + if sel.target == crate::Target::Slides + && let Some(sl) = doc.slides.get_mut(sel.slide) + { + Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape)); + } + sel.shapes.retain(|i| !ids.contains(i)); + sel.text = None; + Ok(json!({"deleted": n})) + }) +} + +/// Remove shapes by id anywhere in the tree; empty groups go too. +pub(crate) fn remove_shapes(shapes: &mut Vec, ids: &[ShapeId]) -> usize { + let before = count(shapes); + fn rec(v: &mut Vec, ids: &[ShapeId], depth: usize) { + v.retain(|s| !ids.contains(&s.id)); + if depth > 64 { + return; + } + for s in v.iter_mut() { + if let Some(ch) = s.children_mut() { + rec(ch, ids, depth + 1); + } + } + v.retain(|s| !s.is_group() || !s.children().is_empty()); + } + rec(shapes, ids, 0); + before.saturating_sub(count(shapes)) +} + +fn count(v: &[Shape]) -> usize { + let mut n = 0; + slidecraft_model::walk(v, &mut |_, _| n += 1); + n +} + +fn select_all(s: &mut Session, _p: &Value) -> Result { + if s.doc()?.selection.text.is_some() { + return s.execute("text.selectAll", &json!({})); + } + s.select(|_, sel| { + sel.text = None; + })?; + let ids: Vec = s.doc()?.shapes().iter().filter(|x| !x.hidden && !x.locked).map(|x| x.id).collect(); + s.select(|_, sel| sel.shapes = ids.clone())?; + Ok(json!({"ids": ids})) +} + +fn deselect(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + if let Some(t) = sel.text.take() { + // Esc while editing text selects the shape. + if !t.notes { + sel.shapes = vec![t.shape]; + } + } else { + sel.shapes.clear(); + sel.cells = None; + } + })?; + ok() +} + +fn select(s: &mut Session, p: &Value) -> Result { + let ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).unwrap_or_default(); + let add = bool_or(p, "add", false); + let toggle = bool_or(p, "toggle", false); + let valid: Vec = ids.into_iter().filter(|i| s.active().is_some_and(|d| d.shape(*i).is_some())).collect(); + s.select(|_, sel| { + sel.text = None; + sel.cells = None; + if toggle { + for i in &valid { + if let Some(k) = sel.shapes.iter().position(|x| x == i) { + sel.shapes.remove(k); + } else { + sel.shapes.push(*i); + } + } + } else if add { + for i in &valid { + if !sel.shapes.contains(i) { + sel.shapes.push(*i); + } + } + } else { + sel.shapes = valid.clone(); + } + })?; + Ok(json!({"selected": s.doc()?.selection.shapes})) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs new file mode 100644 index 0000000..a03a5ab --- /dev/null +++ b/crates/engine/src/cmd/file.rs @@ -0,0 +1,288 @@ +//! File menu: new, open, save, export, close, properties. + +use serde_json::{Value, json}; +use slidecraft_model::{Presentation, defaults}; + +use super::*; +use crate::{DocState, EngineError, Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "file.new", "New Presentation", ["File"], Some("Cmd+N"), "{theme?: name, size?: [w,h] pt, blank?: bool}", always, new), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path}", always, open), + cmd!(noundo "file.openBytes", "Open Data", [], None, "{name, data: base64}", always, open_bytes), + cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?, format?: slidecraft|pptx}", has_doc, save), + cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), + cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), + cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: slidecraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|slidecraft|outline|pdf, slide?: index, scale?: px per pt}", has_doc, export), + cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), + cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close), + cmd!( + "file.properties", + "Properties", + ["File"], + None, + "{title?, subject?, author?, keywords?, comments?, category?, company?} → properties", + has_doc, + properties + ), + cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert), + cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window), + ] +} + +fn theme_named(name: &str) -> Option { + slidecraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)) +} + +fn new(s: &mut Session, p: &Value) -> Result { + let theme = str_param(p, "theme").and_then(theme_named).unwrap_or_default(); + let size = p + .get("size") + .and_then(Value::as_array) + .and_then(|a| Some(slidecraft_geom::Size::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))) + .filter(|s| s.width >= 1.0 && s.height >= 1.0 && s.width < 5000.0 && s.height < 5000.0) + .unwrap_or(defaults::WIDE); + let doc = defaults::blank_presentation(size, theme, !bool_or(p, "blank", false)); + let title = s.next_untitled(); + let i = s.add_document(DocState::new(doc, None, title)); + Ok(json!({"document": i})) +} + +/// Recognise and read presentation bytes (native, PPTX). +pub fn open_presentation(name: &str, bytes: &[u8]) -> Result { + if slidecraft_format::sniff(bytes) { + return slidecraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string())); + } + if slidecraft_pptx::sniff(bytes) { + let mut p = slidecraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + slidecraft_format::repair(&mut p); + return Ok(p); + } + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".txt") || lower.ends_with(".md") { + let text = String::from_utf8_lossy(bytes); + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + slidecraft_format::outline_to_slides(&mut p, &text); + if p.slides.is_empty() { + p = defaults::new_presentation(None); + } + return Ok(p); + } + Err(EngineError::Other(format!("{name}: not a presentation SlideCraft can read"))) +} + +pub fn add_opened(s: &mut Session, name: &str, path: Option, p: Presentation) -> usize { + let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string()); + s.add_document(DocState::new(p, path, title)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn open(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let doc = open_presentation(path, &bytes)?; + let native = slidecraft_format::sniff(&bytes) || slidecraft_pptx::sniff(&bytes); + let i = add_opened(s, path, native.then(|| path.to_string()), doc); + Ok(json!({"document": i, "slides": s.doc()?.doc.slides.len()})) +} +#[cfg(target_arch = "wasm32")] +fn open(_s: &mut Session, _p: &Value) -> Result { + Err(EngineError::Other("use file.openBytes in the browser".into())) +} + +fn open_bytes(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("Presentation"); + let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("file.openBytes", "missing or invalid base64 `data`"))?; + let doc = open_presentation(name, &data)?; + let i = add_opened(s, name, None, doc); + Ok(json!({"document": i})) +} + +/// Encode the presentation in `format` (`slidecraft`, `pptx`, `outline`). +pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { + match format { + "pptx" | "potx" | "ppsx" => slidecraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())), + "outline" | "txt" => Ok(slidecraft_format::slides_to_outline(doc).into_bytes()), + _ => slidecraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), + } +} + +pub fn format_for_path(path: &str) -> &'static str { + let l = path.to_ascii_lowercase(); + if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") { + "pptx" + } else if l.ends_with(".png") { + "png" + } else if l.ends_with(".jpg") || l.ends_with(".jpeg") { + "jpeg" + } else if l.ends_with(".pdf") { + "pdf" + } else if l.ends_with(".txt") || l.ends_with(".md") { + "outline" + } else { + "slidecraft" + } +} + +fn mark_saved(s: &mut Session, path: Option) -> Result<()> { + let st = s.doc_mut()?; + st.saved_doc = st.doc.clone(); + if path.is_some() { + st.path = path; + } + st.revision += 1; + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + // Write to a temporary sibling then rename, so a failed save never truncates the old file. + let tmp = format!("{path}.saving"); + std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) +} +#[cfg(target_arch = "wasm32")] +fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other("saving to a path is not available in the browser".into())) +} + +fn save(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").map(String::from).or_else(|| s.active().and_then(|d| d.path.clone())); + let Some(path) = path else { + s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveAs".into() }); + return Ok(json!({"needsPath": true})); + }; + let format = str_param(p, "format").unwrap_or(format_for_path(&path)); + let bytes = save_bytes(&s.doc()?.doc, format)?; + write_file(&path, &bytes)?; + mark_saved(s, Some(path.clone()))?; + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +fn save_as(s: &mut Session, p: &Value) -> Result { + if str_param(p, "path").is_none() { + s.ui_requests.push(crate::UiRequest::PickFile { purpose: "saveAs".into() }); + return Ok(json!({"needsPath": true})); + } + save(s, p) +} + +fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result { + let format = str_param(p, "format").unwrap_or("slidecraft"); + let bytes = save_bytes(&s.doc()?.doc, format)?; + Ok(json!({"data": base64_encode(&bytes), "bytes": bytes.len()})) +} + +/// Render slide `index` (current when absent) to PNG bytes. +pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) { + let img = + slidecraft_render::render_slide(doc, index, &slidecraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() }); + (img.to_png(), img.width, img.height) +} + +fn render(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "slide").unwrap_or(st.selection.slide); + if i >= st.doc.slides.len() { + return Err(bad("file.render", format!("no slide {i}"))); + } + let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false)); + Ok(json!({"png": base64_encode(&png), "width": w, "height": h})) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string(); + let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string(); + let st = s.doc()?; + let scale = f64_or(p, "scale", 2.0); + match format.as_str() { + "png" | "jpeg" | "jpg" => { + let slides: Vec = match usize_param(p, "slide") { + Some(i) => vec![i], + None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(), + None => vec![st.selection.slide], + }; + let mut written = vec![]; + for (k, i) in slides.iter().enumerate() { + if *i >= st.doc.slides.len() { + return Err(bad("file.export", format!("no slide {i}"))); + } + let img = slidecraft_render::render_slide( + &st.doc, + *i, + &slidecraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() }, + ); + let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) }; + let out = if slides.len() == 1 { + path.clone() + } else { + let pth = std::path::Path::new(&path); + let stem = pth.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "Slide".into()); + let ext = pth.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| format.clone()); + pth.with_file_name(format!("{stem}{}.{ext}", k + 1)).to_string_lossy().to_string() + }; + write_file(&out, &bytes)?; + written.push(out); + } + Ok(json!({"files": written})) + } + "pdf" => Err(EngineError::Other("PDF export is not available yet".into())), + other => { + let bytes = save_bytes(&st.doc, other)?; + write_file(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len()})) + } + } +} + +fn close(s: &mut Session, _p: &Value) -> Result { + if let Some(i) = s.active_index() { + s.close_document(i); + } + ok() +} + +fn properties(s: &mut Session, p: &Value) -> Result { + let fields = ["title", "subject", "author", "keywords", "comments", "category", "company"]; + if fields.iter().any(|f| p.get(*f).is_some()) { + s.edit(|doc, _| { + let pr = &mut doc.props; + for f in fields { + if let Some(v) = str_param(p, f) { + let slot = match f { + "title" => &mut pr.title, + "subject" => &mut pr.subject, + "author" => &mut pr.author, + "keywords" => &mut pr.keywords, + "comments" => &mut pr.comments, + "category" => &mut pr.category, + _ => &mut pr.company, + }; + *slot = v.to_string(); + } + } + Ok(()) + })?; + } + Ok(serde_json::to_value(&s.doc()?.doc.props).unwrap_or_default()) +} + +fn revert(s: &mut Session, _p: &Value) -> Result { + let st = s.doc_mut()?; + st.doc = st.saved_doc.clone(); + st.history = crate::History { limit: st.history.limit, ..Default::default() }; + st.revision += 1; + ok() +} + +fn next_window(s: &mut Session, p: &Value) -> Result { + let n = s.documents().len(); + if n == 0 { + return ok(); + } + let i = usize_param(p, "index").unwrap_or_else(|| (s.active_index().unwrap_or(0) + 1) % n); + s.set_active(i.min(n - 1)); + ok() +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs new file mode 100644 index 0000000..9841dc5 --- /dev/null +++ b/crates/engine/src/cmd/format.rs @@ -0,0 +1,620 @@ +//! Home ▸ Font and Paragraph, Format menu: character, paragraph and text box formatting, and +//! the Format Painter. + +use serde_json::{Value, json}; +use slidecraft_model::edit as ed; +use slidecraft_model::resolve; +use slidecraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TextDir}; +use slidecraft_model::{Effects, Fill, Line, Shape}; + +use super::text::{format_body, format_para, format_run}; +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!("format.bold", "Bold", ["Home", "Font"], Some("Cmd+B"), "{on?: bool}", has_text_or_shapes, bold), + cmd!("format.italic", "Italic", ["Home", "Font"], Some("Cmd+I"), "{on?: bool}", has_text_or_shapes, italic), + cmd!( + "format.underline", + "Underline", + ["Home", "Font"], + Some("Cmd+U"), + "{on?: bool, style?: sng|dbl|heavy|dotted|dash|wavy}", + has_text_or_shapes, + underline + ), + cmd!( + "format.strikethrough", + "Strikethrough", + ["Home", "Font"], + Some("Cmd+Shift+X"), + "{on?: bool, double?: bool}", + has_text_or_shapes, + strike + ), + cmd!("format.superscript", "Superscript", ["Home", "Font"], Some("Cmd+Shift+="), "{on?: bool}", has_text_or_shapes, superscript), + cmd!("format.subscript", "Subscript", ["Home", "Font"], Some("Cmd+="), "{on?: bool}", has_text_or_shapes, subscript), + cmd!("format.font", "Font", ["Home", "Font"], None, "{family}", has_text_or_shapes, font), + cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size), + cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow), + cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink), + cmd!( + "format.color", + "Font Color", + ["Home", "Font"], + None, + "{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}", + has_text_or_shapes, + color + ), + cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight), + cmd!("format.case", "Change Case", ["Home", "Font"], Some("Shift+F3"), "{mode: sentence|lower|upper|title|toggle}", has_text_or_shapes, case), + cmd!("format.caps", "Caps", [], None, "{caps: none|small|all}", has_text_or_shapes, caps), + cmd!( + "format.spacing", + "Character Spacing", + ["Home", "Font"], + None, + "{pt} (veryTight -3, tight -1.5, normal 0, loose 3, veryLoose 6)", + has_text_or_shapes, + spacing + ), + cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear), + cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow), + cmd!( + "format.textOutline", + "Text Outline", + ["Shape Format", "WordArt Styles"], + None, + "{color?, width?: pt} (no color = none)", + has_text_or_shapes, + text_outline + ), + cmd!("format.align", "Align", ["Home", "Paragraph"], None, "{align: left|center|right|justify|distributed}", has_text_or_shapes, align), + cmd!("format.alignLeft", "Align Left", ["Home", "Paragraph"], Some("Cmd+L"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Left)), + cmd!("format.alignCenter", "Center", ["Home", "Paragraph"], Some("Cmd+E"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Center)), + cmd!("format.alignRight", "Align Right", ["Home", "Paragraph"], Some("Cmd+R"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Right)), + cmd!("format.justify", "Justify", ["Home", "Paragraph"], Some("Cmd+J"), "{}", has_text_or_shapes, |s, _| align_to(s, Align::Justify)), + cmd!( + "format.bullets", + "Bullets", + ["Home", "Paragraph"], + None, + "{on?: bool, char?: •|○|■|□|◆|➢|✓|–, color?, size?: % of text}", + has_text_or_shapes, + bullets + ), + cmd!( + "format.numbering", + "Numbering", + ["Home", "Paragraph"], + None, + "{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n}", + has_text_or_shapes, + numbering + ), + cmd!("format.indent", "Increase List Level", ["Home", "Paragraph"], Some("Tab"), "{}", has_text_or_shapes, |s, _| level(s, 1)), + cmd!("format.outdent", "Decrease List Level", ["Home", "Paragraph"], Some("Shift+Tab"), "{}", has_text_or_shapes, |s, _| level(s, -1)), + cmd!( + "format.lineSpacing", + "Line Spacing", + ["Home", "Paragraph"], + None, + "{lines?: 1.0|1.5|2.0…, pt?: exactly}", + has_text_or_shapes, + line_spacing + ), + cmd!( + "format.paragraph", + "Paragraph…", + ["Format"], + None, + "{align?, indentLeft?: pt, indentFirst?: pt (negative = hanging), spaceBefore?: pt, spaceAfter?: pt, lineSpacing?: lines}", + has_text_or_shapes, + paragraph + ), + cmd!("format.anchor", "Align Text", ["Home", "Paragraph"], None, "{anchor: top|middle|bottom}", has_text_or_shapes, anchor), + cmd!( + "format.direction", + "Text Direction", + ["Home", "Paragraph"], + None, + "{dir: horizontal|rotate90|rotate270|stacked}", + has_text_or_shapes, + direction + ), + cmd!("format.columns", "Columns", ["Home", "Paragraph"], None, "{count, spacing?: pt}", has_text_or_shapes, columns), + cmd!("format.autofit", "Autofit", ["Format Shape", "Text Box"], None, "{mode: none|shrink|resize}", has_text_or_shapes, autofit), + cmd!("format.wrap", "Wrap Text in Shape", ["Format Shape", "Text Box"], None, "{on: bool}", has_text_or_shapes, wrap), + cmd!( + "format.margins", + "Text Box Margins", + ["Format Shape", "Text Box"], + None, + "{left?, top?, right?, bottom?: pt}", + has_text_or_shapes, + margins + ), + cmd!(noundo "format.painter", "Format Painter", ["Home", "Clipboard"], Some("Cmd+Shift+C"), "{sticky?: bool}", has_text_or_shapes, painter_pick), + cmd!("format.painterApply", "Paste Formatting", [], Some("Cmd+Shift+V"), "{ids?}", has_text_or_shapes, painter_apply), + cmd!(query "format.state", "Formatting State", [], None, "{} → the effective formatting at the selection (font, size, bold, …)", has_doc, state), + ] +} + +fn on_param(p: &Value, current: Option) -> bool { + bool_param(p, "on").unwrap_or(!current.unwrap_or(false)) +} + +/// The effective run props at the selection (first selected run). +fn current_run(s: &Session) -> RunProps { + let Ok(st) = s.doc() else { return RunProps::default() }; + let ctx = super::text::ctx_for(&st.doc, &st.selection); + let (shape, para, run) = match &st.selection.text { + Some(t) => { + let Some(body) = super::text::body_of(st, t) else { return RunProps::default() }; + let a = t.ordered().0; + let rp = if t.is_range() { ed::props_at(body, (a.0, a.1 + 1)) } else { ed::props_at(body, a) }; + let para = body.paragraphs.get(a.0).cloned().unwrap_or_default(); + (st.shape(t.shape).cloned().unwrap_or_default(), para, rp) + } + None => { + let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return RunProps::default() }; + let para = sh.text.as_ref().and_then(|t| t.paragraphs.first().cloned()).unwrap_or_default(); + let rp = para.runs.first().map(|r| r.props.clone()).unwrap_or_default(); + (sh, para, rp) + } + }; + match ctx { + Some(c) => resolve::run(&c, &shape, ¶, &run), + None => run, + } +} + +fn current_para(s: &Session) -> ParaProps { + let Ok(st) = s.doc() else { return ParaProps::default() }; + let ctx = super::text::ctx_for(&st.doc, &st.selection); + let (shape, para) = match &st.selection.text { + Some(t) => { + let para = super::text::body_of(st, t).and_then(|b| b.paragraphs.get(t.caret.0).cloned()).unwrap_or_default(); + (st.shape(t.shape).cloned().unwrap_or_default(), para) + } + None => { + let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ParaProps::default() }; + let para = sh.text.as_ref().and_then(|t| t.paragraphs.first().cloned()).unwrap_or_default(); + (sh, para) + } + }; + match ctx { + Some(c) => resolve::para(&c, &shape, ¶), + None => para.props, + } +} + +fn bold(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, current_run(s).bold); + format_run(s, &move |r| r.bold = Some(v)) +} +fn italic(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, current_run(s).italic); + format_run(s, &move |r| r.italic = Some(v)) +} +fn underline(s: &mut Session, p: &Value) -> Result { + let cur = current_run(s).underline.is_some_and(|u| u != "none"); + let v = on_param(p, Some(cur)); + let style = str_param(p, "style").unwrap_or("sng").to_string(); + format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() })) +} +fn strike(s: &mut Session, p: &Value) -> Result { + let cur = current_run(s).strike.is_some_and(|x| x != Strike::None); + let v = on_param(p, Some(cur)); + let d = bool_or(p, "double", false); + format_run(s, &move |r| { + r.strike = Some(if !v { + Strike::None + } else if d { + Strike::Double + } else { + Strike::Single + }) + }) +} +fn superscript(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0)); + format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 })) +} +fn subscript(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0)); + format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 })) +} +fn font(s: &mut Session, p: &Value) -> Result { + let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string(); + format_run(s, &move |r| r.font = Some(f.clone())) +} +fn size(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "size").ok_or_else(|| bad("format.size", "missing `size`"))?.clamp(1.0, 4000.0); + format_run(s, &move |r| r.size = Some(v)) +} + +/// PowerPoint's font size list (grow/shrink steps through it). +pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0]; + +fn step(cur: f64, up: bool) -> f64 { + if up { + SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0)) + } else { + SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0)) + } +} + +fn grow_shrink(s: &mut Session, up: bool) -> Result { + // Each run steps from its own size, like PowerPoint. + let st = s.doc()?; + let ctx_sizes = current_run(s).size.unwrap_or(18.0); + let _ = st; + let base = ctx_sizes; + format_run(s, &move |r| { + let cur = r.size.unwrap_or(base); + r.size = Some(step(cur, up)); + }) +} +fn grow(s: &mut Session, _p: &Value) -> Result { + grow_shrink(s, true) +} +fn shrink(s: &mut Session, _p: &Value) -> Result { + grow_shrink(s, false) +} +fn color(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; + format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) +} +fn highlight(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color"); + format_run(s, &move |r| r.highlight = c.clone()) +} +fn caps(s: &mut Session, p: &Value) -> Result { + let c = match str_param(p, "caps").unwrap_or("none") { + "small" => Caps::Small, + "all" => Caps::All, + _ => Caps::None, + }; + format_run(s, &move |r| r.caps = Some(c)) +} +fn spacing(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); + format_run(s, &move |r| r.spacing = Some(v)) +} +fn clear(s: &mut Session, _p: &Value) -> Result { + format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) +} +fn text_shadow(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).shadow.is_some())); + let sh = slidecraft_model::style::Shadow { + color: slidecraft_model::ColorRef::rgb(slidecraft_color::Rgba::BLACK).with(slidecraft_color::ColorTransform::Alpha(43000)), + blur: 3.0, + dist: 2.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }; + format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None }) +} +fn text_outline(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color"); + let w = f64_or(p, "width", 0.75); + format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w))) +} + +fn case(s: &mut Session, p: &Value) -> Result { + let mode = str_param(p, "mode").unwrap_or("sentence").to_string(); + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let (a, b) = t.ordered(); + if a == b { + return ok(); + } + let m = mode.clone(); + super::text::edit_text(s, move |body, _| { + for pi in a.0..=b.0 { + let Some(para) = body.paragraphs.get_mut(pi) else { continue }; + let n = para.char_len(); + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { n }; + let mut pos = 0; + for r in &mut para.runs { + let len = r.char_len(); + if r.kind == slidecraft_model::text::RunKind::Text { + let rs = from.max(pos).saturating_sub(pos); + let re = to.min(pos + len).saturating_sub(pos); + if re > rs { + let chars: Vec = r.text.chars().collect(); + let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default(); + let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default(); + let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default(); + r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m)); + } + } + pos += len; + } + } + })?; + return ok(); + } + edit_shapes(s, &json!({}), "format.case", |sh| { + if let Some(t) = sh.text.as_mut() { + for para in &mut t.paragraphs { + for r in &mut para.runs { + if r.kind == slidecraft_model::text::RunKind::Text { + r.text = ed::change_case(&r.text, &mode); + } + } + } + } + Ok(()) + }) +} + +fn align_to(s: &mut Session, a: Align) -> Result { + format_para(s, &move |p| p.props.align = Some(a)) +} +fn align(s: &mut Session, p: &Value) -> Result { + let a = match str_param(p, "align").unwrap_or("left") { + "center" | "centre" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "distributed" | "dist" => Align::Distributed, + _ => Align::Left, + }; + align_to(s, a) +} + +fn bullets(s: &mut Session, p: &Value) -> Result { + let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. })); + let on = on_param(p, Some(cur)); + let ch = str_param(p, "char").unwrap_or("•").to_string(); + let col = color_param(p, "color"); + let size = f64_param(p, "size").map(|v| v / 100.0); + let explicit_char = p.get("char").is_some(); + format_para(s, &move |para| { + if on { + para.props.bullet = Some(Bullet::Char { char: ch.clone() }); + para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() }; + if col.is_some() { + para.props.bullet_color = col.clone(); + } + if size.is_some() { + para.props.bullet_size = size; + } + if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) { + para.props.margin_left = Some(18.0 + 36.0 * para.level as f64); + para.props.indent = Some(-18.0); + } + } else { + para.props.bullet = Some(Bullet::None); + para.props.indent = Some(0.0); + para.props.margin_left = Some(36.0 * para.level as f64); + } + }) +} + +fn numbering(s: &mut Session, p: &Value) -> Result { + let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. })); + let on = on_param(p, Some(cur)); + let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string(); + let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1); + format_para(s, &move |para| { + if on { + para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start }); + para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); + para.props.indent = Some(-27.0); + } else { + para.props.bullet = Some(Bullet::None); + para.props.indent = Some(0.0); + para.props.margin_left = Some(36.0 * para.level as f64); + } + }) +} + +fn level(s: &mut Session, d: i32) -> Result { + format_para(s, &move |p| { + let l = (p.level as i32 + d).clamp(0, 8) as u8; + // Explicit indents shift with the level. + if l != p.level { + if let Some(m) = p.props.margin_left.as_mut() { + *m = (*m + 36.0 * d as f64).max(0.0); + } + p.level = l; + } + }) +} + +fn line_spacing(s: &mut Session, p: &Value) -> Result { + let sp = + if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) }; + format_para(s, &move |para| para.props.line_spacing = Some(sp)) +} + +fn paragraph(s: &mut Session, p: &Value) -> Result { + let a = str_param(p, "align").map(|a| match a { + "center" => Align::Center, + "right" => Align::Right, + "justify" => Align::Justify, + "distributed" => Align::Distributed, + _ => Align::Left, + }); + let ml = f64_param(p, "indentLeft"); + let ind = f64_param(p, "indentFirst"); + let sb = f64_param(p, "spaceBefore"); + let sa = f64_param(p, "spaceAfter"); + let ls = f64_param(p, "lineSpacing"); + let lsp = f64_param(p, "lineSpacingPt"); + format_para(s, &move |para| { + if a.is_some() { + para.props.align = a; + } + if ml.is_some() { + para.props.margin_left = ml.map(|v| v.max(0.0)); + } + if ind.is_some() { + para.props.indent = ind; + } + if let Some(v) = sb { + para.props.space_before = Some(Spacing::Pts(v.max(0.0))); + } + if let Some(v) = sa { + para.props.space_after = Some(Spacing::Pts(v.max(0.0))); + } + if let Some(v) = ls { + para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99))); + } + if let Some(v) = lsp { + para.props.line_spacing = Some(Spacing::Pts(v.max(0.0))); + } + }) +} + +fn anchor(s: &mut Session, p: &Value) -> Result { + let a = match str_param(p, "anchor").unwrap_or("top") { + "middle" | "center" | "ctr" => Anchor::Middle, + "bottom" | "b" => Anchor::Bottom, + _ => Anchor::Top, + }; + format_body(s, &move |b| b.anchor = Some(a)) +} +fn direction(s: &mut Session, p: &Value) -> Result { + let d = match str_param(p, "dir").unwrap_or("horizontal") { + "rotate90" | "vert" => TextDir::Vertical, + "rotate270" | "vert270" => TextDir::Vertical270, + "stacked" => TextDir::Stacked, + _ => TextDir::Horizontal, + }; + format_body(s, &move |b| b.vert = Some(d)) +} +fn columns(s: &mut Session, p: &Value) -> Result { + let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 16) as u32; + let sp = f64_or(p, "spacing", 0.0).max(0.0); + format_body(s, &move |b| { + b.columns = Some(n); + b.col_spacing = Some(sp); + }) +} +fn autofit(s: &mut Session, p: &Value) -> Result { + let m = match str_param(p, "mode").unwrap_or("none") { + "shrink" => AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }, + "resize" | "shape" => AutoFit::Shape, + _ => AutoFit::None, + }; + format_body(s, &move |b| b.autofit = Some(m)) +} +fn wrap(s: &mut Session, p: &Value) -> Result { + let on = bool_or(p, "on", true); + format_body(s, &move |b| b.wrap = Some(on)) +} +fn margins(s: &mut Session, p: &Value) -> Result { + let (l, t, r, b) = (f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")); + format_body(s, &move |bp| { + if l.is_some() { + bp.inset_l = l.map(|v| v.max(0.0)); + } + if t.is_some() { + bp.inset_t = t.map(|v| v.max(0.0)); + } + if r.is_some() { + bp.inset_r = r.map(|v| v.max(0.0)); + } + if b.is_some() { + bp.inset_b = b.map(|v| v.max(0.0)); + } + }) +} + +/// Formatting picked up by the Format Painter. +#[derive(Clone, Debug, Default)] +pub struct Painted { + pub run: Option, + pub para: Option, + pub fill: Option, + pub line: Option, + pub effects: Option, + pub style: Option, +} + +fn painter_pick(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let mut painted = Painted::default(); + if let Some(t) = &st.selection.text { + if let Some(b) = super::text::body_of(st, t) { + let a = t.ordered().0; + painted.run = Some(ed::props_at(b, (a.0, a.1 + 1))); + painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone()); + } + } else if let Some(sh) = st.selected_shapes().first() { + painted.fill = sh.fill.clone(); + painted.line = sh.line.clone(); + painted.effects = sh.effects.clone(); + painted.style = sh.style.clone(); + painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()); + } + s.painter = Some((painted, bool_or(p, "sticky", false))); + Ok(json!({"armed": true})) +} + +pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) { + if p.fill.is_some() || p.style.is_some() { + sh.fill.clone_from(&p.fill); + sh.line.clone_from(&p.line); + sh.effects.clone_from(&p.effects); + sh.style.clone_from(&p.style); + } + if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) { + ed::format_all(t, &|rp| *rp = r.clone()); + } +} + +fn painter_apply(s: &mut Session, p: &Value) -> Result { + let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) }; + if !sticky { + s.painter = None; + } + if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() { + if let Some(r) = painted.run.clone() { + format_run(s, &move |rp| *rp = r.clone())?; + } + if let Some(pp) = painted.para.clone() { + format_para(s, &move |para| para.props = pp.clone())?; + } + return ok(); + } + edit_shapes(s, p, "format.painterApply", |sh| { + apply_painted(sh, &painted); + Ok(()) + }) +} + +fn state(s: &mut Session, _p: &Value) -> Result { + let r = current_run(s); + let pp = current_para(s); + let st = s.doc()?; + let family = super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default(); + Ok(json!({ + "font": family, + "fontRef": r.font, + "size": r.size, + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().is_some_and(|u| u != "none"), + "strike": r.strike.is_some_and(|x| x != Strike::None), + "superscript": r.baseline.unwrap_or(0.0) > 0.0, + "subscript": r.baseline.unwrap_or(0.0) < 0.0, + "align": pp.align.map(|a| a.xml()), + "bullets": matches!(pp.bullet, Some(Bullet::Char { .. })), + "numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })), + "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), + "painter": s.painter.is_some(), + })) +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs new file mode 100644 index 0000000..fa5c3a0 --- /dev/null +++ b/crates/engine/src/cmd/insert.rs @@ -0,0 +1,541 @@ +//! Insert tab: shapes, text boxes, pictures, tables, charts, audio, video, WordArt, fields. + +use serde_json::{Value, json}; +use slidecraft_color::SchemeSlot; +use slidecraft_geom::Xfrm; +use slidecraft_model::chart::ChartType; +use slidecraft_model::style::PictureFill; +use slidecraft_model::text::{AutoFit, RunKind, TextBody}; +use slidecraft_model::{Chart, ColorRef, Fill, Geom, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table}; + +use super::*; +use crate::{EngineError, Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "shape.insert", + "Shapes", + ["Insert", "Illustrations"], + None, + "{preset: rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets), rect: [x,y,w,h] pt, text?: string}", + has_slide, + insert_shape + ), + cmd!(query "shape.presets", "Shape Gallery", [], None, "{} → [{name, label, category}]", always, presets), + cmd!("insert.textBox", "Text Box", ["Insert", "Text"], None, "{rect?: [x,y,w,h], text?: string}", has_slide, text_box), + cmd!( + "insert.picture", + "Picture from File…", + ["Insert", "Images", "Pictures"], + None, + "{path? | data: base64, name?, rect?: [x,y,w,h]}", + has_slide, + picture + ), + cmd!("insert.table", "Table…", ["Insert", "Tables"], None, "{rows, cols, rect?}", has_slide, table), + cmd!( + "insert.chart", + "Chart…", + ["Insert", "Illustrations"], + None, + "{type?: column|bar|line|pie|doughnut|area|scatter|stackedColumn|…, rect?, categories?: [..], series?: [{name, values}]}", + has_slide, + chart + ), + cmd!("insert.audio", "Audio from File…", ["Insert", "Media", "Audio"], None, "{path? | data: base64, name?, rect?}", has_slide, audio), + cmd!("insert.video", "Video from File…", ["Insert", "Media", "Video"], None, "{path? | data: base64, name?, rect?}", has_slide, video), + cmd!("insert.wordArt", "WordArt", ["Insert", "Text"], None, "{text?, style?: 0..}", has_slide, word_art), + cmd!("insert.slideNumber", "Slide Number", ["Insert", "Text"], None, "{}", has_slide, slide_number), + cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time), + cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol), + cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink), + cmd!( + "insert.actionButton", + "Action Buttons", + ["Insert", "Illustrations"], + None, + "{kind: back|forward|beginning|end|home|information|return|movie|document|sound|help|custom, rect?}", + has_slide, + action_button + ), + ] +} + +/// Default size for a click-inserted shape (1 inch square, PowerPoint style). +fn default_rect(s: &Session, w: f64, h: f64) -> Xfrm { + let size = s.active().map(|d| d.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE); + Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) +} + +pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Result { + s.edit(|doc, sel| { + shape.id = ShapeId(doc.alloc_id()); + if shape.name.is_empty() { + let base = match &shape.kind { + ShapeKind::Picture { .. } => "Picture".to_string(), + ShapeKind::Table(_) => "Table".to_string(), + ShapeKind::Chart(_) => "Chart".to_string(), + ShapeKind::Media(m) if m.video => "Video".to_string(), + ShapeKind::Media(_) => "Audio".to_string(), + _ if shape.text_box => "TextBox".to_string(), + _ => shape + .geom + .preset_name() + .and_then(slidecraft_geom::preset::info) + .map(|i| i.label.split(':').next().unwrap_or(i.label).trim().to_string()) + .unwrap_or_else(|| "Shape".into()), + }; + let n = super::current_shapes(doc, sel).map(|v| v.len()).unwrap_or(0) + 1; + shape.name = format!("{base} {n}"); + } + let id = shape.id; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?; + list.push(shape); + if select { + sel.shapes = vec![id]; + sel.text = None; + } + Ok(id) + }) +} + +fn insert_shape(s: &mut Session, p: &Value) -> Result { + let preset = str_param(p, "preset").unwrap_or("rect"); + let info = slidecraft_geom::preset::info(preset).ok_or_else(|| bad("shape.insert", format!("unknown preset `{preset}`")))?; + let line_like = slidecraft_geom::preset::is_line_like(preset); + let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 72.0, if line_like { 0.0 } else { 72.0 })); + let adj: Vec = p.get("adj").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let mut shape = Shape { + xfrm: Some(rect), + geom: Geom::Preset { name: info.name.into(), adj }, + style: Some(if line_like { ShapeStyle::line(SchemeSlot::Accent1) } else { ShapeStyle::accent(SchemeSlot::Accent1) }), + ..Default::default() + }; + if let Some(fl) = p.get("flip").and_then(Value::as_str) + && let Some(x) = shape.xfrm.as_mut() + { + x.flip_h = fl.contains('h'); + x.flip_v = fl.contains('v'); + } + if !line_like { + shape.text = Some(TextBody { paragraphs: vec![], ..Default::default() }); + if let Some(t) = str_param(p, "text") { + shape.text = Some(TextBody::from_text(t)); + } + if let Some(tb) = shape.text.as_mut() { + for para in &mut tb.paragraphs { + para.props.align = Some(slidecraft_model::text::Align::Center); + } + } + } else { + shape.kind = if preset.contains("Connector") { ShapeKind::Connector { start: None, end: None } } else { ShapeKind::Shape }; + if preset == "straightConnector1" { + shape.line = Some(slidecraft_model::Line { + tail: Some(slidecraft_model::style::LineEnd { kind: "triangle".into(), w: "med".into(), len: "med".into() }), + ..Default::default() + }); + } + } + if let (Some(look), false) = (&s.default_look, line_like) { + super::format::apply_painted(&mut shape, look); + } + let id = add_shape(s, shape, true)?; + Ok(json!({"id": id})) +} + +fn presets(_s: &mut Session, _p: &Value) -> Result { + Ok(Value::Array( + slidecraft_geom::preset::CATALOG.iter().map(|i| json!({"name": i.name, "label": i.label, "category": i.category.label()})).collect(), + )) +} + +pub(crate) fn new_text_box(rect: Xfrm, text: &str) -> Shape { + let mut body = TextBody::from_text(text); + body.body.wrap = Some(true); + body.body.autofit = Some(AutoFit::Shape); + Shape { xfrm: Some(rect), text_box: true, fill: None, text: Some(body), geom: Geom::preset("rect"), ..Default::default() } +} + +fn text_box(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").unwrap_or(""); + let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 216.0, 36.0)); + let mut shape = new_text_box(rect, text); + // Click-to-create (no width) text boxes grow sideways without wrapping. + if rect.w < 1.0 { + shape.xfrm = Some(Xfrm { w: 100.0, ..rect }); + if let Some(t) = shape.text.as_mut() { + t.body.wrap = Some(false); + } + } + let id = add_shape(s, shape, true)?; + super::text::fit_text_box(s, id)?; + if text.is_empty() { + // Start typing immediately. + s.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = Some(crate::TextSel { shape: id, ..Default::default() }); + })?; + } + Ok(json!({"id": id})) +} + +/// Read media bytes from `data` (base64) or `path`. +pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { + if let Some(d) = str_param(p, "data") { + let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = str_param(p, "path") { + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); + return Ok((name, bytes)); + } + Err(bad(cmd, "missing `path` or `data`")) +} + +pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { + let l = name.to_ascii_lowercase(); + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { + "image/png" + } else if bytes.starts_with(&[0xFF, 0xD8]) { + "image/jpeg" + } else if bytes.starts_with(b"GIF8") { + "image/gif" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { + "audio/wav" + } else if bytes.starts_with(b"BM") { + "image/bmp" + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + "image/tiff" + } else if bytes.starts_with(b"fLaC") { + "audio/flac" + } else if bytes.starts_with(b"OggS") { + if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } + } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { + "audio/mpeg" + } else if bytes.starts_with(b"FORM") { + "audio/aiff" + } else if bytes.get(4..8) == Some(b"ftyp") { + if l.ends_with(".m4a") || bytes.get(8..11) == Some(b"M4A") { + "audio/mp4" + } else if l.ends_with(".mov") { + "video/quicktime" + } else { + "video/mp4" + } + } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { + "video/webm" + } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { + let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) + .unwrap_or((288.0, 216.0)); + let size = s.active().map(|d| d.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE); + let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); + (w * k, h * k) +} + +fn picture(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = media_bytes(p, "insert.picture")?; + let ct = content_type(&name, &bytes); + if !ct.starts_with("image/") { + return Err(bad("insert.picture", format!("{name} is not a picture"))); + } + if ct != "image/svg+xml" && slidecraft_render::decode_image(&bytes).is_none() { + return Err(EngineError::Other(format!("{name}: the picture can't be read"))); + } + let (w, h) = fitted(s, &bytes); + // Into a selected empty picture/content placeholder, if there is one. + let target_ph = s.active().and_then(|d| { + d.selected_shapes() + .into_iter() + .find(|x| { + x.ph_type() + .is_some_and(|k| matches!(k, slidecraft_model::PhType::Picture | slidecraft_model::PhType::Obj | slidecraft_model::PhType::Body)) + && x.text.as_ref().is_none_or(|t| t.is_empty()) + }) + .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) + }); + let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { + Some((_, b)) => { + // Fill the placeholder, cropping the picture to its aspect. + b + } + None => default_rect(s, w, h), + }); + let crop = match (target_ph, rect_param(p, "rect")) { + (Some(_), None) => { + let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); + if ia > ba { + let c = (1.0 - ba / ia) / 2.0; + [c, 0.0, c, 0.0] + } else { + let c = (1.0 - ia / ba) / 2.0; + [0.0, c, 0.0, c] + } + } + _ => [0.0; 4], + }; + let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + let shape = Shape { + xfrm: Some(rect), + kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, + descr: name.clone(), + ..Default::default() + }; + if let Some((ph_id, _)) = target_ph { + // Replace the placeholder with the picture (it keeps the placeholder link). + let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; + if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { + sh.kind = shape.kind.clone(); + sh.xfrm = Some(rect); + sh.ph = phv.map(|mut p| { + p.kind = slidecraft_model::PhType::Picture; + p + }); + sh.text = None; + sh.descr = name.clone(); + } + sel.shapes = vec![ph_id]; + Ok(()) + })?; + return Ok(json!({"id": ph_id})); + } + let id = add_shape(s, shape, true)?; + Ok(json!({"id": id})) +} + +fn table(s: &mut Session, p: &Value) -> Result { + let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); + let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); + let size = s.doc()?.doc.slide_size; + let w = size.width * 0.8; + let rh = 40.0; + let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.1, (size.height - rh * rows as f64).max(0.0) / 2.0, w, rh * rows as f64)); + let mut t = Table::new(rows, cols, rect.w, rect.h / rows as f64); + if let Some(data) = p.get("data").and_then(Value::as_array) { + for (r, row) in data.iter().enumerate() { + for (c, v) in row.as_array().map(|a| a.as_slice()).unwrap_or(&[]).iter().enumerate() { + if let (Some(cell), Some(text)) = (t.cell_mut(r, c), v.as_str()) { + cell.text = TextBody::from_text(text); + } + } + } + } + let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Table(t), ..Default::default() }, true)?; + Ok(json!({"id": id})) +} + +pub fn chart_type(name: &str) -> ChartType { + match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { + "bar" | "clusteredbar" => ChartType::Bar, + "stackedbar" => ChartType::StackedBar, + "percentbar" => ChartType::PercentBar, + "line" => ChartType::Line, + "linemarkers" | "linewithmarkers" => ChartType::LineMarkers, + "stackedline" => ChartType::StackedLine, + "area" => ChartType::Area, + "stackedarea" => ChartType::StackedArea, + "pie" => ChartType::Pie, + "doughnut" | "donut" => ChartType::Doughnut, + "scatter" | "xy" => ChartType::Scatter, + "bubble" => ChartType::Bubble, + "radar" => ChartType::Radar, + "filledradar" => ChartType::FilledRadar, + "stackedcolumn" => ChartType::StackedColumn, + "percentcolumn" => ChartType::PercentColumn, + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "histogram" => ChartType::Histogram, + "pareto" => ChartType::Pareto, + "treemap" => ChartType::Treemap, + "sunburst" => ChartType::Sunburst, + "boxwhisker" | "box" => ChartType::BoxWhisker, + "stock" => ChartType::Stock, + "surface" => ChartType::Surface, + "combo" => ChartType::Combo, + _ => ChartType::Column, + } +} + +fn chart(s: &mut Session, p: &Value) -> Result { + let kind = chart_type(str_param(p, "type").unwrap_or("column")); + let mut c = Chart::sample(kind); + if let Some(cats) = p.get("categories").and_then(Value::as_array) { + c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect(); + } + if let Some(series) = p.get("series").and_then(Value::as_array) { + c.series = series + .iter() + .map(|sv| slidecraft_model::chart::Series { + name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), + values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), + ..Default::default() + }) + .collect(); + } + if let Some(t) = p.get("title") { + c.title = t.as_str().map(String::from); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); + let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; + Ok(json!({"id": id})) +} + +fn media(s: &mut Session, p: &Value, video: bool) -> Result { + let cmd = if video { "insert.video" } else { "insert.audio" }; + let (name, bytes) = media_bytes(p, cmd)?; + let ct = content_type(&name, &bytes); + if video && !ct.starts_with("video/") { + return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))); + } + if !video && !ct.starts_with("audio/") { + return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or_else(|| { + if video { + Xfrm::new(size.width * 0.2, size.height * 0.2, size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) + } else { + Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) + } + }); + let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + let clip = MediaClip { media, video, volume: 1.0, ..Default::default() }; + let id = add_shape(s, Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; + Ok(json!({"id": id, "contentType": ct})) +} + +fn audio(s: &mut Session, p: &Value) -> Result { + media(s, p, false) +} +fn video(s: &mut Session, p: &Value) -> Result { + media(s, p, true) +} + +fn word_art(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").unwrap_or("Your text here"); + let size = s.doc()?.doc.slide_size; + let style = usize_param(p, "style").unwrap_or(0); + let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text); + if let Some(t) = shape.text.as_mut() { + for para in &mut t.paragraphs { + para.props.align = Some(slidecraft_model::text::Align::Center); + for r in &mut para.runs { + r.props.size = Some(54.0); + r.props.bold = Some(style % 2 == 1); + r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { + 0 => SchemeSlot::Tx1, + 1 => SchemeSlot::Accent1, + 2 => SchemeSlot::Accent2, + _ => SchemeSlot::Accent4, + }))); + if style % 3 == 2 { + r.props.outline = Some(slidecraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); + } + } + } + } + let id = add_shape(s, shape, true)?; + super::text::fit_text_box(s, id)?; + Ok(json!({"id": id})) +} + +fn field_run(s: &mut Session, field: &str, text: &str) -> Result { + if s.doc()?.selection.text.is_some() { + return super::text::insert_field(s, field, text); + } + // Not editing: turn on the footer field for the slide (Header & Footer). + let key = if field == "slidenum" { "slideNumber" } else { "date" }; + s.execute("design.headerFooter", &json!({key: true})) +} + +fn slide_number(s: &mut Session, _p: &Value) -> Result { + field_run(s, "slidenum", "‹#›") +} +fn date_time(s: &mut Session, p: &Value) -> Result { + let f = str_param(p, "format").unwrap_or("datetime1").to_string(); + field_run(s, &f, "") +} + +fn symbol(s: &mut Session, p: &Value) -> Result { + let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string(); + if s.doc()?.selection.text.is_none() { + return Err(bad("insert.symbol", "place the insertion point in text first")); + } + super::text::insert_text(s, &t, None) +} + +fn hyperlink(s: &mut Session, p: &Value) -> Result { + use slidecraft_model::text::{Action, Hyperlink}; + let action = if let Some(u) = str_param(p, "url") { + Action::Url { url: u.to_string() } + } else if let Some(i) = usize_param(p, "slide") { + let id = s.doc()?.doc.slides.get(i).map(|x| x.id).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; + Action::Slide { slide: id } + } else { + return Err(bad("insert.hyperlink", "missing `url` or `slide`")); + }; + let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false }; + if s.doc()?.selection.text.is_some() { + let l = link.clone(); + return super::text::format_run(s, &move |rp| rp.link = Some(l.clone())); + } + edit_shapes(s, p, "insert.hyperlink", |sh| { + sh.click = Some(link.clone()); + Ok(()) + }) +} + +fn action_button(s: &mut Session, p: &Value) -> Result { + use slidecraft_model::text::{Action, Hyperlink}; + let kind = str_param(p, "kind").unwrap_or("custom"); + let (preset, action) = match kind { + "back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)), + "forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)), + "beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)), + "end" => ("actionButtonEnd", Some(Action::LastSlide)), + "home" => ("actionButtonHome", Some(Action::FirstSlide)), + "information" => ("actionButtonInformation", None), + "return" => ("actionButtonReturn", Some(Action::LastViewed)), + "movie" => ("actionButtonMovie", None), + "document" => ("actionButtonDocument", None), + "sound" => ("actionButtonSound", None), + "help" => ("actionButtonHelp", None), + _ => ("actionButtonBlank", None), + }; + let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0)); + let shape = Shape { + xfrm: Some(rect), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), + click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true }), + ..Default::default() + }; + let id = add_shape(s, shape, true)?; + Ok(json!({"id": id})) +} + +#[allow(dead_code)] +fn _kinds() -> RunKind { + RunKind::Text +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs new file mode 100644 index 0000000..6b9bd18 --- /dev/null +++ b/crates/engine/src/cmd/inspect.rs @@ -0,0 +1,154 @@ +//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots). + +use serde_json::{Value, json}; +use slidecraft_model::resolve::{self, Ctx}; +use slidecraft_model::{Shape, ShapeKind}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), + cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide), + cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape), + cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands), + ] +} + +fn document(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + let slides: Vec = d + .slides + .iter() + .enumerate() + .map(|(i, sl)| { + let layout = d.layout(sl.layout).map(|(_, l)| l.name.clone()).unwrap_or_default(); + json!({"index": i, "id": sl.id, "title": sl.title(), "layout": layout, "shapes": sl.shapes.len(), "hidden": sl.hidden, "transition": sl.transition.as_ref().map(|t| t.kind.clone()), "animations": sl.animations.len(), "notes": sl.notes_text()}) + }) + .collect(); + let m = d.masters.first(); + Ok(json!({ + "title": st.title(), + "path": st.path, + "dirty": st.is_dirty(), + "slideSize": [d.slide_size.width, d.slide_size.height], + "slides": slides, + "sections": d.sections.iter().map(|x| json!({"name": x.name, "slides": x.slides.len()})).collect::>(), + "theme": m.map(|m| m.theme.name.clone()), + "fonts": m.map(|m| json!({"major": m.theme.fonts.major.latin, "minor": m.theme.fonts.minor.latin})), + "layouts": m.map(|m| m.layouts.iter().map(|l| json!({"id": l.id, "name": l.name})).collect::>()), + "selection": serde_json::to_value(&st.selection).unwrap_or_default(), + "media": d.media.iter().map(|x| json!({"id": x.id, "name": x.name, "type": x.content_type, "bytes": x.data.len()})).collect::>(), + "undo": st.history.undo.last().map(|e| e.label.clone()), + "redo": st.history.redo.last().map(|e| e.label.clone()), + })) +} + +pub fn shape_summary(ctx: Option<&Ctx>, sh: &Shape, depth: usize) -> Value { + let x = ctx.map(|c| resolve::xfrm(c, sh)).or(sh.xfrm).unwrap_or_default(); + let fill = ctx.map(|c| { + let (f, ph) = resolve::fill(c, sh); + match f { + Some(slidecraft_model::Fill::Solid { color }) => json!(format!("#{}", c.color(&color, ph).hex())), + Some(slidecraft_model::Fill::None) | None => json!("none"), + Some(other) => json!(serde_json::to_value(&other).ok().and_then(|v| v.get("kind").cloned()).unwrap_or_default()), + } + }); + let mut v = json!({ + "id": sh.id, + "name": sh.name, + "kind": sh.kind_name(), + "box": [round(x.x), round(x.y), round(x.w), round(x.h)], + "rotation": x.rot, + }); + if let Some(o) = v.as_object_mut() { + if let Some(p) = &sh.ph { + o.insert("placeholder".into(), json!(p.kind.xml())); + } + if let Some(n) = sh.geom.preset_name() + && matches!(sh.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) + { + o.insert("preset".into(), json!(n)); + } + if let Some(t) = &sh.text + && !t.is_empty() + { + o.insert("text".into(), json!(t.text())); + } + if let Some(f) = fill { + o.insert("fill".into(), f); + } + if sh.hidden { + o.insert("hidden".into(), json!(true)); + } + if !sh.descr.is_empty() { + o.insert("altText".into(), json!(sh.descr)); + } + match &sh.kind { + ShapeKind::Table(t) => { + o.insert("table".into(), json!({"rows": t.n_rows(), "cols": t.n_cols(), "style": t.style, "cells": t.rows.iter().map(|r| r.cells.iter().map(|c| c.text.text()).collect::>()).collect::>()})); + } + ShapeKind::Chart(c) => { + o.insert("chart".into(), json!({"type": c.kind.label(), "title": c.title, "categories": c.categories, "series": c.series.iter().map(|s| json!({"name": s.name, "values": s.values})).collect::>()})); + } + ShapeKind::Group { children, .. } if depth < 8 => { + o.insert("children".into(), Value::Array(children.iter().map(|c| shape_summary(None, c, depth + 1)).collect())); + } + ShapeKind::Media(m) => { + o.insert("media".into(), json!({"id": m.media, "video": m.video, "autoplay": m.autoplay, "loop": m.loop_play})); + } + _ => {} + } + } + v +} + +fn round(v: f64) -> f64 { + (v * 100.0).round() / 100.0 +} + +fn slide(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "index").unwrap_or(st.selection.slide); + let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.inspect", format!("no slide {i}")))?; + let ctx = Ctx::for_slide(&st.doc, sl); + let shapes: Vec = sl.shapes.iter().map(|sh| shape_summary(ctx.as_ref(), sh, 0)).collect(); + let bg = ctx.as_ref().map(|c| match resolve::background(c, Some(sl)) { + (slidecraft_model::Fill::Solid { color }, ph) => json!(format!("#{}", c.color(&color, ph).hex())), + (f, _) => serde_json::to_value(&f).ok().and_then(|v| v.get("kind").cloned()).unwrap_or_default(), + }); + Ok(json!({ + "index": i, + "id": sl.id, + "layout": st.doc.layout(sl.layout).map(|(_, l)| l.name.clone()), + "title": sl.title(), + "background": bg, + "shapes": shapes, + "transition": serde_json::to_value(&sl.transition).unwrap_or_default(), + "animations": serde_json::to_value(&sl.animations).unwrap_or_default(), + "notes": sl.notes_text(), + "comments": sl.comments.len(), + "hidden": sl.hidden, + })) +} + +fn shape(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = id_param(p, "id").or_else(|| st.selection.shapes.first().copied()).ok_or_else(|| bad("shape.inspect", "missing `id`"))?; + let sh = st.shape(id).ok_or_else(|| bad("shape.inspect", format!("no shape {id}")))?; + let x = xfrm_of(&st.doc, &st.selection, sh); + Ok(json!({"shape": serde_json::to_value(sh).unwrap_or_default(), "box": x})) +} + +fn commands(s: &mut Session, p: &Value) -> Result { + let f = str_param(p, "filter").unwrap_or("").to_ascii_lowercase(); + Ok(Value::Array( + s.commands() + .into_iter() + .filter(|c| f.is_empty() || c.id.to_ascii_lowercase().contains(&f) || c.label.to_ascii_lowercase().contains(&f)) + .map(|c| serde_json::to_value(c).unwrap_or_default()) + .collect(), + )) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 0000000..3d87422 --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,343 @@ +//! The command registry. Ids follow PowerPoint's ribbon and menu structure. + +pub mod animation; +pub mod arrange; +pub mod design; +pub mod edit; +pub mod file; +pub mod format; +pub mod insert; +pub mod inspect; +pub mod review; +pub mod shape; +pub mod slide; +pub mod table; +pub mod text; +pub mod transition; + +use serde::Serialize; +use serde_json::Value; +use slidecraft_geom::Xfrm; +use slidecraft_model::ShapeId; + +use crate::{EngineError, Result, Session}; + +pub type Run = fn(&mut Session, &Value) -> Result; +pub type Enabled = fn(&Session) -> std::result::Result<(), String>; + +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu). + pub menu: &'static [&'static str], + pub shortcut: Option<&'static str>, + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + pub journal: bool, + pub undoable: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CommandInfo { + pub id: &'static str, + pub label: &'static str, + pub menu: Vec<&'static str>, + pub shortcut: Option<&'static str>, + pub params: &'static str, + pub enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +impl CommandSpec { + pub fn info(&self, s: &Session) -> CommandInfo { + let e = (self.enabled)(s); + CommandInfo { + id: self.id, + label: self.label, + menu: self.menu.to_vec(), + shortcut: self.shortcut, + params: self.params, + enabled: e.is_ok(), + disabled_reason: e.err(), + } + } +} + +pub fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} +pub fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no presentation open".into()) +} +pub fn has_slide(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| !d.doc.slides.is_empty() || d.selection.target != crate::Target::Slides) { Ok(()) } else { Err("no slides".into()) } +} +pub const NOTHING_SELECTED: &str = "nothing selected"; +pub fn has_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) } +} +pub fn has_two(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.shapes.len() >= 2) { Ok(()) } else { Err("select two or more objects".into()) } +} +pub fn has_text(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) } +} +/// Text editing or shapes with text selected (formatting commands apply to either). +pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { + Ok(()) + } else { + Err("select text or a shape".into()) + } +} +pub fn can_undo(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } +} +pub fn can_redo(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| !d.history.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) } +} + +macro_rules! cmd { + ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true } + }; + (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false } + }; + (noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false } + }; +} +pub(crate) use cmd; + +pub fn command_specs() -> &'static [CommandSpec] { + static SPECS: std::sync::OnceLock> = std::sync::OnceLock::new(); + SPECS.get_or_init(|| { + let mut v = Vec::new(); + v.extend(file::specs()); + v.extend(edit::specs()); + v.extend(slide::specs()); + v.extend(insert::specs()); + v.extend(shape::specs()); + v.extend(arrange::specs()); + v.extend(text::specs()); + v.extend(format::specs()); + v.extend(design::specs()); + v.extend(transition::specs()); + v.extend(animation::specs()); + v.extend(table::specs()); + v.extend(review::specs()); + v.extend(inspect::specs()); + v + }) +} + +pub fn find_command(id: &str) -> Option<&'static CommandSpec> { + command_specs().iter().find(|c| c.id == id) +} + +// ---------- param helpers ---------- + +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} +pub(crate) fn f64_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) +} +pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { + f64_param(p, key).unwrap_or(default) +} +pub(crate) fn bool_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(Value::as_bool) +} +pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { + bool_param(p, key).unwrap_or(default) +} +pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + p.get(key).and_then(Value::as_str) +} +pub(crate) fn usize_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()) +} +pub(crate) fn id_param(p: &Value, key: &str) -> Option { + p.get(key).and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).map(ShapeId) +} +pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(ShapeId).collect()) +} +/// `[x, y, w, h]` in points. +pub(crate) fn rect_param(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array()?; + let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()); + Some(Xfrm::new(f(0)?, f(1)?, f(2)?.abs(), f(3)?.abs())) +} +pub(crate) fn with_param(p: &Value, key: &str, v: Value) -> Value { + let mut m = p.as_object().cloned().unwrap_or_default(); + m.insert(key.to_string(), v); + Value::Object(m) +} + +/// Targets: `ids` / `id` params, else the selection. +pub(crate) fn targets(s: &Session, p: &Value) -> Result> { + if let Some(ids) = ids_param(p, "ids") + && !ids.is_empty() + { + return Ok(ids); + } + if let Some(id) = id_param(p, "id") { + return Ok(vec![id]); + } + Ok(s.doc()?.selection.shapes.clone()) +} + +pub(crate) fn ok() -> Result { + Ok(Value::Null) +} + +/// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`. +pub(crate) fn color_param(p: &Value, key: &str) -> Option { + let v = p.get(key)?; + color_value(v) +} + +pub(crate) fn color_value(v: &Value) -> Option { + use slidecraft_model::ColorRef; + match v { + Value::String(s) => { + if let Some(slot) = slidecraft_color::SchemeSlot::from_xml(s) { + return Some(ColorRef::scheme(slot)); + } + if let Some(c) = slidecraft_color::Rgba::from_hex(s) { + return Some(ColorRef::rgb(c)); + } + slidecraft_color::preset(s).map(ColorRef::rgb) + } + Value::Object(o) => { + let mut c = if let Some(s) = o.get("scheme").and_then(Value::as_str) { + ColorRef::scheme(slidecraft_color::SchemeSlot::from_xml(s)?) + } else { + ColorRef::rgb(slidecraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?) + }; + for (k, val) in o { + if let Some(t) = slidecraft_color::ColorTransform::from_xml(k, val.as_i64().and_then(|x| i32::try_from(x).ok())) { + c.mods.push(t); + } + } + Some(c) + } + _ => None, + } +} + +/// Look a shape up by id in the edited tree. +pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result { + s.doc()?.shape(id).cloned().ok_or_else(|| EngineError::Other(format!("no shape {id}"))) +} + +/// Effective box of a shape (placeholders inherit theirs). +pub(crate) fn xfrm_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm { + if let Some(x) = shape.xfrm { + return x; + } + ctx_xfrm(doc, sel, shape) +} + +fn ctx_xfrm(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm { + use slidecraft_model::resolve::{self, Ctx}; + match sel.target { + crate::Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)).map(|c| resolve::xfrm(&c, shape)), + crate::Target::Master { master } => doc.masters.get(master).map(|m| resolve::xfrm(&Ctx::for_master(doc, m), shape)), + crate::Target::Layout { master, layout } => { + doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| resolve::xfrm(&Ctx::for_layout(doc, m, l), shape))) + } + } + .unwrap_or_default() +} + +/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first. +pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut slidecraft_model::Shape) -> Result<()>) -> Result { + let ids = targets(s, p)?; + if ids.is_empty() { + return Err(bad(cmd, "no shapes selected")); + } + s.edit(|doc, sel| { + let boxes: Vec<(ShapeId, Xfrm)> = { + let st_shapes: Vec = + ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| slidecraft_model::find_shape(v, *id)).cloned()).collect(); + st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect() + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let mut n = 0; + for id in &ids { + if let Some(sh) = slidecraft_model::find_shape_mut(shapes, *id) { + if sh.xfrm.is_none() { + sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x); + } + f(sh)?; + n += 1; + } + } + if n == 0 { + return Err(bad(cmd, "no such shape")); + } + Ok(Value::from(n)) + }) +} + +pub(crate) fn current_shapes<'a>(doc: &'a slidecraft_model::Presentation, sel: &crate::Selection) -> Option<&'a Vec> { + match sel.target { + crate::Target::Slides => doc.slides.get(sel.slide).map(|s| &s.shapes), + crate::Target::Master { master } => doc.masters.get(master).map(|m| &m.shapes), + crate::Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| &l.shapes), + } +} + +/// Base64 (standard alphabet) for binary payloads in JSON params and results. +pub fn base64_encode(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + for (i, shift) in [18, 12, 6, 0].iter().enumerate() { + if i <= chunk.len() { + out.push(A.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A') as char); + } else { + out.push('='); + } + } + } + out +} + +pub fn base64_decode(s: &str) -> Option> { + let s = s.split_once("base64,").map(|(_, b)| b).unwrap_or(s); + let mut out = Vec::with_capacity(s.len() / 4 * 3); + let mut acc = 0u32; + let mut bits = 0; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' | b'\n' | b'\r' | b' ' => continue, + _ => return None, + } as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xff) as u8); + } + } + Some(out) +} diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs new file mode 100644 index 0000000..20356a9 --- /dev/null +++ b/crates/engine/src/cmd/review.rs @@ -0,0 +1,266 @@ +//! Review tab: comments, spelling, accessibility; Slide Show requests. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_model::Comment; + +use super::*; +use crate::{Result, Session, UiRequest}; + +pub fn specs() -> Vec { + vec![ + cmd!("comment.add", "New Comment", ["Review", "Comments"], Some("Cmd+Alt+M"), "{text, x?, y?, id?: shape}", has_slide, add), + cmd!("comment.reply", "Reply", ["Comments"], None, "{index, text}", has_slide, reply), + cmd!("comment.delete", "Delete Comment", ["Review", "Comments"], None, "{index? | all?: bool}", has_slide, delete), + cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), + cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), + cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), + cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{} → [{slide, shape, word}] (words not in the built-in list)", has_doc, spelling), + cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)), + cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| { + let i = s.doc()?.selection.slide; + start(s, i) + }), + cmd!( + "show.setup", + "Set Up Slide Show…", + ["Slide Show", "Set Up"], + None, + "{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}", + has_doc, + setup + ), + cmd!( + "show.customShow", + "Custom Slide Show", + ["Slide Show", "Start Slide Show"], + None, + "{name, slides: [index]} | {name, delete: true}", + has_doc, + custom_show + ), + ] +} + +fn now() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (y, m, d) = super::design::civil((secs / 86_400) as i64); + let t = secs % 86_400; + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) + } + #[cfg(target_arch = "wasm32")] + { + String::new() + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("comment.add", "missing `text`"))?.to_string(); + let author = s.prefs.author.clone(); + let shape = id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.shapes.first().copied())); + let (x, y) = (f64_or(p, "x", 10.0), f64_or(p, "y", 10.0)); + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.add", "no slide"))?); + let initials: String = author.split_whitespace().filter_map(|w| w.chars().next()).collect(); + sl.comments.push(Comment { author: author.clone(), initials, text, date: now(), x, y, resolved: false, replies: vec![], shape }); + Ok(json!({"index": sl.comments.len() - 1})) + }) +} + +fn reply(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad("comment.reply", "missing `index`"))?; + let text = str_param(p, "text").unwrap_or("").to_string(); + let author = s.prefs.author.clone(); + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.reply", "no slide"))?); + let c = sl.comments.get_mut(i).ok_or_else(|| bad("comment.reply", "no such comment"))?; + c.replies.push(Comment { author, text, date: now(), ..Default::default() }); + Ok(()) + })?; + ok() +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let all = bool_or(p, "all", false); + let i = usize_param(p, "index"); + s.edit(|doc, sel| { + if all { + for sl in &mut doc.slides { + Arc::make_mut(sl).comments.clear(); + } + return Ok(()); + } + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.delete", "no slide"))?); + match i { + Some(i) if i < sl.comments.len() => { + sl.comments.remove(i); + } + None => sl.comments.clear(), + _ => return Err(bad("comment.delete", "no such comment")), + } + Ok(()) + })?; + ok() +} + +fn resolve(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad("comment.resolve", "missing `index`"))?; + let v = bool_or(p, "resolved", true); + s.edit(|doc, sel| { + let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("comment.resolve", "no slide"))?); + sl.comments.get_mut(i).ok_or_else(|| bad("comment.resolve", "no such comment"))?.resolved = v; + Ok(()) + })?; + ok() +} + +fn list(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "slide").unwrap_or(st.selection.slide); + Ok(serde_json::to_value(st.doc.slides.get(i).map(|x| x.comments.clone()).unwrap_or_default()).unwrap_or_default()) +} + +fn accessibility(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + if sl.title().trim().is_empty() { + out.push(json!({"slide": i, "issue": "Missing slide title"})); + } + slidecraft_model::walk(&sl.shapes, &mut |sh, _| { + let needs_alt = matches!( + sh.kind, + slidecraft_model::ShapeKind::Picture { .. } + | slidecraft_model::ShapeKind::Chart(_) + | slidecraft_model::ShapeKind::Media(_) + | slidecraft_model::ShapeKind::Group { .. } + ); + if needs_alt && sh.descr.trim().is_empty() && !sh.decorative { + out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": "Missing alternative text"})); + } + }); + // Low-contrast text against the slide background. + if let Some(ctx) = slidecraft_model::resolve::Ctx::for_slide(&st.doc, sl) { + let bg = match slidecraft_model::resolve::background(&ctx, Some(sl)) { + (slidecraft_model::Fill::Solid { color }, ph) => Some(ctx.color(&color, ph)), + _ => None, + }; + if let Some(bg) = bg { + for sh in &sl.shapes { + let Some(t) = &sh.text else { continue }; + let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue }; + let rp = slidecraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default()); + let (fill, _) = slidecraft_model::resolve::fill(&ctx, sh); + if fill.as_ref().is_some_and(|f| !f.is_none()) { + continue; + } + let fg = slidecraft_model::resolve::text_color(&ctx, &rp); + if fg.contrast(bg) < 3.0 { + out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))})); + } + } + } + } + } + Ok(Value::Array(out)) +} + +fn spelling(s: &mut Session, _p: &Value) -> Result { + // A small built-in list of frequent misspellings (full dictionaries come later). + const COMMON: &[&str] = &[ + "teh", + "recieve", + "seperate", + "occured", + "untill", + "wich", + "becuase", + "definately", + "thier", + "alot", + "accomodate", + "acheive", + "adress", + "begining", + "beleive", + "calender", + "commited", + "enviroment", + "goverment", + "independant", + "neccessary", + "occassion", + "publically", + "reccomend", + "tommorow", + "wierd", + ]; + let st = s.doc()?; + let mut out = vec![]; + for (i, sl) in st.doc.slides.iter().enumerate() { + slidecraft_model::walk(&sl.shapes, &mut |sh, _| { + if let Some(t) = &sh.text { + for w in t.text().split(|c: char| !c.is_alphanumeric() && c != '\'') { + if COMMON.contains(&w.to_lowercase().as_str()) { + out.push(json!({"slide": i, "shape": sh.id, "word": w})); + } + } + } + }); + } + Ok(Value::Array(out)) +} + +fn start(s: &mut Session, from: usize) -> Result { + s.ui_requests.push(UiRequest::StartShow { from }); + Ok(json!({"from": from})) +} + +fn setup(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let sh = &mut doc.show; + if let Some(t) = str_param(p, "type") { + sh.show_type = t.to_string(); + if t == "kiosk" { + sh.loop_until_esc = true; + } + } + if let Some(v) = bool_param(p, "loop") { + sh.loop_until_esc = v; + } + if let Some(v) = bool_param(p, "noNarration") { + sh.without_narration = v; + } + if let Some(v) = bool_param(p, "noAnimation") { + sh.without_animation = v; + } + if let Some(v) = bool_param(p, "useTimings") { + sh.use_timings = v; + } + match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) { + (Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)), + (None, None) if p.get("all").is_some() => sh.range = None, + _ => {} + } + Ok(serde_json::to_value(&*sh).unwrap_or_default()) + }) +} + +fn custom_show(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("show.customShow", "missing `name`"))?.to_string(); + let delete = bool_or(p, "delete", false); + let idx: Vec = + p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()).unwrap_or_default(); + s.edit(|doc, _| { + doc.custom_shows.retain(|c| c.name != name); + if !delete { + let slides = idx.iter().filter_map(|i| doc.slides.get(*i).map(|x| x.id)).collect(); + doc.custom_shows.push(slidecraft_model::CustomShow { name: name.clone(), slides }); + } + Ok(()) + })?; + ok() +} diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs new file mode 100644 index 0000000..dbbc522 --- /dev/null +++ b/crates/engine/src/cmd/shape.rs @@ -0,0 +1,666 @@ +//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles, +//! names and alt text. + +use serde_json::{Value, json}; +use slidecraft_color::{ColorTransform, Rgba, SchemeSlot}; +use slidecraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, Reflection, Shadow}; +use slidecraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by), + cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize), + cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds), + cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate), + cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip), + cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust), + cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change), + cmd!( + "shape.fill", + "Shape Fill", + ["Shape Format", "Shape Styles"], + None, + "{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}", + has_selection, + fill + ), + cmd!( + "shape.line", + "Shape Outline", + ["Shape Format", "Shape Styles"], + None, + "{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?, join?, head?: none|triangle|stealth|diamond|oval|arrow, tail?, reset?, ids?}", + has_selection, + line + ), + cmd!( + "shape.effects", + "Shape Effects", + ["Shape Format", "Shape Styles"], + None, + "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, reset?, ids?}", + has_selection, + effects + ), + cmd!( + "shape.quickStyle", + "Quick Styles", + ["Home", "Drawing"], + None, + "{index: 0..41 (theme style row×accent), ids?}", + has_selection, + quick_style + ), + cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename), + cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text), + cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible), + cmd!("shape.lock", "Lock", [], None, "{locked?: bool, ids?}", has_selection, lock), + cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default), + cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop), + cmd!( + "picture.adjust", + "Corrections", + ["Picture Format", "Adjust"], + None, + "{brightness?: -1..1, contrast?: -1..1, saturation?: 0..4, grayscale?: bool, transparency?: 0..1, reset?: bool, ids?}", + has_selection, + picture_adjust + ), + cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset), + cmd!( + "picture.change", + "Change Picture", + ["Picture Format", "Adjust"], + None, + "{path? | data: base64, name?, ids?}", + has_selection, + picture_change + ), + cmd!( + "media.options", + "Playback", + ["Playback"], + None, + "{autoplay?, loop?, rewind?, acrossSlides?, hide?, fullScreen?, volume?: 0..1, trimStart?: ms, trimEnd?: ms, fadeIn?: ms, fadeOut?: ms, ids?}", + has_selection, + media_options + ), + ] +} + +fn move_by(s: &mut Session, p: &Value) -> Result { + let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy")); + let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y")); + edit_shapes(s, p, "shape.move", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0); + x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0); + } + Ok(()) + }) +} + +fn resize(s: &mut Session, p: &Value) -> Result { + let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0))); + let lock = bool_or(p, "lockAspect", false); + edit_shapes(s, p, "shape.resize", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 }; + let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0); + match (w, h, lock) { + (Some(w), _, true) => { + x.w = w; + x.h = if ar > 0.0 { w / ar } else { x.h }; + } + (None, Some(h), true) => { + x.h = h; + x.w = h * ar; + } + _ => { + x.w = w.unwrap_or(x.w); + x.h = h.unwrap_or(x.h); + } + } + // Rotated shapes resize about their centre; others keep the top-left. + if x.rot != 0.0 { + x.x = cx - x.w / 2.0; + x.y = cy - x.h / 2.0; + } + } + Ok(()) + }) +} + +fn set_bounds(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "shape.setBounds", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + x.x = f64_param(p, "x").unwrap_or(x.x); + x.y = f64_param(p, "y").unwrap_or(x.y); + x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0); + x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0); + } + Ok(()) + }) +} + +fn rotate(s: &mut Session, p: &Value) -> Result { + let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); + edit_shapes(s, p, "shape.rotate", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0); + x.rot = ((r % 360.0) + 360.0) % 360.0; + } + Ok(()) + }) +} + +fn flip(s: &mut Session, p: &Value) -> Result { + let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v'); + edit_shapes(s, p, "shape.flip", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + if v { + x.flip_v = !x.flip_v; + } else { + x.flip_h = !x.flip_h; + } + } + Ok(()) + }) +} + +fn adjust(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").unwrap_or(0).min(16); + let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?; + edit_shapes(s, p, "shape.adjust", |sh| { + if let Geom::Preset { name, adj } = &mut sh.geom { + let defaults = slidecraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); + while adj.len() <= i { + let k = adj.len(); + adj.push(defaults.get(k).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(i) { + *slot = v; + } + } + Ok(()) + }) +} + +fn change(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; + let info = slidecraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; + edit_shapes(s, p, "shape.change", |sh| { + sh.geom = Geom::preset(info.name); + Ok(()) + }) +} + +fn gradient_param(v: &Value) -> Option { + let stops: Vec = v + .get("stops")? + .as_array()? + .iter() + .filter_map(|s| { + let a = s.as_array()?; + Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? }) + }) + .collect(); + if stops.len() < 2 { + return None; + } + let shape = match v.get("kind").and_then(Value::as_str) { + Some("radial" | "path") => GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] }, + _ => GradientShape::Linear { angle: v.get("angle").and_then(Value::as_f64).unwrap_or(90.0), scaled: false }, + }; + Some(Gradient { stops, shape, rotate_with_shape: true }) +} + +fn fill(s: &mut Session, p: &Value) -> Result { + let new_fill: Option> = if bool_or(p, "reset", false) { + Some(None) + } else if bool_or(p, "none", false) { + Some(Some(Fill::None)) + } else if let Some(c) = color_param(p, "color") { + Some(Some(Fill::solid(c))) + } else if let Some(g) = p.get("gradient") { + Some(Some(Fill::Gradient( + gradient_param(g).ok_or_else(|| bad("shape.fill", "gradient needs at least two `stops`: [[0, \"#fff\"], [1, \"#000\"]]"))?, + ))) + } else if let Some(pt) = p.get("pattern") { + Some(Some(Fill::Pattern(PatternFill { + preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), + fg: pt.get("fg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)), + bg: pt.get("bg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Bg1)), + }))) + } else if p.get("picture").is_some() { + let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "shape.fill")?; + let ct = super::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + Some(Some(Fill::Picture(PictureFill { media, ..Default::default() }))) + } else { + None + }; + let transparency = f64_param(p, "transparency"); + if new_fill.is_none() && transparency.is_none() { + return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `transparency` or `reset`")); + } + // Transparency on an inherited (style) fill needs the effective colour. + let st = s.doc()?; + let ctx_fill: Vec<(slidecraft_model::ShapeId, Option)> = { + let ctx = super::text::ctx_for(&st.doc, &st.selection); + targets(s, p)? + .into_iter() + .filter_map(|id| { + st.shape(id).map(|sh| { + ( + id, + ctx.as_ref().and_then(|c| { + let (f, ph) = slidecraft_model::resolve::fill(c, sh); + f.map(|f| match f { + Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))), + other => other, + }) + }), + ) + }) + }) + .collect() + }; + edit_shapes(s, p, "shape.fill", |sh| { + if let Some(f) = &new_fill { + sh.fill.clone_from(f); + } + if let Some(t) = transparency { + let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone())); + if let Some(Fill::Solid { mut color }) = base { + color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if t > 0.0 { + color.mods.push(ColorTransform::Alpha(((1.0 - t.clamp(0.0, 1.0)) * 100_000.0).round() as i32)); + } + sh.fill = Some(Fill::Solid { color }); + } + } + Ok(()) + }) +} + +fn line_end(v: &str) -> Option { + if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } +} + +fn line(s: &mut Session, p: &Value) -> Result { + let reset = bool_or(p, "reset", false); + let none = bool_or(p, "none", false); + let color = color_param(p, "color"); + let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0)); + let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() }); + let head = str_param(p, "head").map(line_end); + let tail = str_param(p, "tail").map(line_end); + let cap = str_param(p, "cap").map(|c| match c { + "round" => slidecraft_model::style::LineCap::Round, + "square" => slidecraft_model::style::LineCap::Square, + _ => slidecraft_model::style::LineCap::Flat, + }); + let join = str_param(p, "join").map(|c| match c { + "bevel" => slidecraft_model::style::LineJoin::Bevel, + "miter" => slidecraft_model::style::LineJoin::Miter, + _ => slidecraft_model::style::LineJoin::Round, + }); + edit_shapes(s, p, "shape.line", |sh| { + if reset { + sh.line = None; + return Ok(()); + } + let l = sh.line.get_or_insert_with(Line::default); + if none { + l.fill = Some(Fill::None); + } + if let Some(c) = &color { + l.fill = Some(Fill::solid(c.clone())); + } + if width.is_some() { + l.width = width; + if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none { + l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))); + } + } + if dash.is_some() { + l.dash.clone_from(&dash); + } + if let Some(h) = &head { + l.head.clone_from(h); + } + if let Some(t) = &tail { + l.tail.clone_from(t); + } + if cap.is_some() { + l.cap = cap; + } + if join.is_some() { + l.join = join; + } + Ok(()) + }) +} + +fn shadow_preset(v: &Value) -> Option> { + let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a)); + let base = |inner: bool| Shadow { + color: black(40000), + blur: 4.0, + dist: 3.0, + dir: 45.0, + inner, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "tl".into(), + rotate_with_shape: false, + }; + match v { + Value::String(s) => Some(match s.as_str() { + "none" => None, + "inner" => Some(base(true)), + "perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }), + "bottom" => Some(Shadow { dir: 90.0, ..base(false) }), + "center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }), + _ => Some(base(false)), + }), + Value::Object(o) => Some(Some(Shadow { + color: o.get("color").and_then(color_value).unwrap_or(black(40000)), + blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0), + dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0), + dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0), + inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false), + ..base(false) + })), + _ => None, + } +} + +fn effects(s: &mut Session, p: &Value) -> Result { + let reset = bool_or(p, "reset", false); + let shadow = p.get("shadow").and_then(shadow_preset); + let glow: Option> = p.get("glow").map(|g| match g { + Value::Object(o) => Some(Glow { + color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))), + radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0), + }), + Value::Number(n) => Some(Glow { + color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), + radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0), + }), + _ => None, + }); + let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0))); + let refl = str_param(p, "reflection").map(|r| match r { + "none" => None, + "half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }), + "full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }), + _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }), + }); + let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) }); + edit_shapes(s, p, "shape.effects", |sh| { + if reset { + sh.effects = None; + return Ok(()); + } + let e = sh.effects.get_or_insert_with(Effects::default); + if let Some(sw) = &shadow { + match sw { + Some(x) if x.inner => { + e.inner_shadow = Some(x.clone()); + e.outer_shadow = None; + } + Some(x) => { + e.outer_shadow = Some(x.clone()); + e.inner_shadow = None; + } + None => { + e.outer_shadow = None; + e.inner_shadow = None; + } + } + } + if let Some(g) = &glow { + e.glow.clone_from(g); + } + if let Some(sf) = soft { + e.soft_edge = sf; + } + if let Some(r) = &refl { + e.reflection.clone_from(r); + } + if let Some(b) = &bevel { + e.bevel.clone_from(b); + } + Ok(()) + }) +} + +/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents. +pub fn quick_style_for(index: usize) -> (ShapeStyle, Option, Option) { + let slots = [ + SchemeSlot::Dk1, + SchemeSlot::Accent1, + SchemeSlot::Accent2, + SchemeSlot::Accent3, + SchemeSlot::Accent4, + SchemeSlot::Accent5, + SchemeSlot::Accent6, + ]; + let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1); + let row = index / 7; + let mut st = ShapeStyle::accent(slot); + let c = ColorRef::scheme(slot); + match row { + 0 => { + // Colored outline, light fill. + st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1)); + st.line_ref = (2, c); + st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1)); + (st, None, None) + } + 1 => (st, None, None), + 2 => { + st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000))); + st.line_ref = (0, c.clone()); + st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000))); + (st, None, Some(Line::none())) + } + 3 => { + st.fill_ref = (2, c); + (st, None, Some(Line::none())) + } + 4 => { + st.fill_ref = (3, c.clone()); + st.effect_ref = (3, c); + (st, None, Some(Line::none())) + } + _ => { + st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000))); + st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1)); + (st, None, None) + } + } +} + +fn quick_style(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").unwrap_or(1).min(41); + let (st, f, l) = quick_style_for(i); + edit_shapes(s, p, "shape.quickStyle", |sh| { + sh.style = Some(st.clone()); + sh.fill.clone_from(&f); + sh.line.clone_from(&l); + sh.effects = None; + if let Some(t) = sh.text.as_mut() { + slidecraft_model::edit::format_all(t, &|r| r.fill = None); + } + Ok(()) + }) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("shape.rename", "missing `name`"))?.to_string(); + edit_shapes(s, p, "shape.rename", |sh| { + sh.name = name.clone(); + Ok(()) + }) +} + +fn alt_text(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").unwrap_or("").to_string(); + let deco = bool_param(p, "decorative"); + edit_shapes(s, p, "shape.altText", |sh| { + sh.descr = text.clone(); + if let Some(d) = deco { + sh.decorative = d; + } + Ok(()) + }) +} + +fn visible(s: &mut Session, p: &Value) -> Result { + let v = bool_param(p, "visible"); + edit_shapes(s, p, "shape.visible", |sh| { + sh.hidden = !v.unwrap_or(sh.hidden); + Ok(()) + }) +} + +fn lock(s: &mut Session, p: &Value) -> Result { + let v = bool_param(p, "locked"); + edit_shapes(s, p, "shape.lock", |sh| { + sh.locked = v.unwrap_or(!sh.locked); + Ok(()) + }) +} + +fn set_default(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() }; + // New shapes drawn in this session take this look (Set as Default Shape). + s.default_look = Some(super::format::Painted { + run: sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()), + para: None, + fill: sh.fill.clone(), + line: sh.line.clone(), + effects: sh.effects.clone(), + style: sh.style.clone(), + }); + ok() +} + +fn crop(s: &mut Session, p: &Value) -> Result { + let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")]; + edit_shapes(s, p, "picture.crop", |sh| { + if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind { + for (i, v) in c.iter().enumerate() { + if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) { + *slot = v.clamp(-10.0, 0.95); + } + } + } + Ok(()) + }) +} + +fn picture_adjust(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "picture.adjust", |sh| { + if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind { + let a = &mut fill.adjust; + if bool_or(p, "reset", false) { + *a = Default::default(); + } + if let Some(v) = f64_param(p, "brightness") { + a.brightness = v.clamp(-1.0, 1.0); + } + if let Some(v) = f64_param(p, "contrast") { + a.contrast = v.clamp(-1.0, 1.0); + } + if let Some(v) = f64_param(p, "saturation") { + a.saturation = Some(v.clamp(0.0, 4.0)); + } + if let Some(v) = bool_param(p, "grayscale") { + a.grayscale = v; + } + if let Some(v) = f64_param(p, "transparency") { + fill.alpha = Some((1.0 - v).clamp(0.0, 1.0)); + } + } + Ok(()) + }) +} + +fn picture_reset(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "picture.reset", |sh| { + if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind { + fill.adjust = Default::default(); + fill.crop = [0.0; 4]; + fill.alpha = None; + } + sh.effects = None; + sh.line = None; + Ok(()) + }) +} + +fn picture_change(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = super::insert::media_bytes(p, "picture.change")?; + let ct = super::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?; + edit_shapes(s, p, "picture.change", |sh| { + if let slidecraft_model::ShapeKind::Picture { fill } = &mut sh.kind { + fill.media = media; + fill.crop = [0.0; 4]; + } + Ok(()) + }) +} + +fn media_options(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "media.options", |sh| { + if let slidecraft_model::ShapeKind::Media(m) = &mut sh.kind { + if let Some(v) = bool_param(p, "autoplay") { + m.autoplay = v; + } + if let Some(v) = bool_param(p, "loop") { + m.loop_play = v; + } + if let Some(v) = bool_param(p, "rewind") { + m.rewind = v; + } + if let Some(v) = bool_param(p, "acrossSlides") { + m.play_across_slides = v; + } + if let Some(v) = bool_param(p, "hide") { + m.hide_while_not_playing = v; + } + if let Some(v) = bool_param(p, "fullScreen") { + m.full_screen = v; + } + if let Some(v) = f64_param(p, "volume") { + m.volume = v.clamp(0.0, 1.0); + } + let ms = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32); + if let Some(v) = ms("trimStart") { + m.trim_start_ms = v; + } + if let Some(v) = ms("trimEnd") { + m.trim_end_ms = v; + } + if let Some(v) = ms("fadeIn") { + m.fade_in_ms = v; + } + if let Some(v) = ms("fadeOut") { + m.fade_out_ms = v; + } + } + Ok(()) + })?; + Ok(json!({})) +} diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs new file mode 100644 index 0000000..a706262 --- /dev/null +++ b/crates/engine/src/cmd/slide.rs @@ -0,0 +1,586 @@ +//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_model::text::TextBody; +use slidecraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults}; + +use super::*; +use crate::{Result, Session, Target}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "slide.new", + "New Slide", + ["Home", "Slides"], + Some("Cmd+Shift+N"), + "{layout?: layout name or kind (title, titleAndContent, sectionHeader, twoContent, comparison, titleOnly, blank, contentWithCaption, pictureWithCaption), at?: index, title?: text, body?: text}", + has_doc, + new + ), + cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate), + cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete), + cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide), + cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go), + cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next), + cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev), + cmd!(noundo "slide.first", "First Slide", [], Some("Home"), "{}", has_slide, first), + cmd!(noundo "slide.last", "Last Slide", [], Some("End"), "{}", has_slide, last), + cmd!(noundo "slide.selectSlides", "Select Slides", [], None, "{ids?: [slide id], indices?: [index], add?: bool}", has_slide, select_slides), + cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide), + cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout), + cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset), + cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes), + cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename), + cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add), + cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename), + cmd!( + "section.remove", + "Remove Section", + ["Home", "Slides", "Section"], + None, + "{index, slides?: bool (also delete its slides)}", + has_slide, + section_remove + ), + cmd!("section.removeAll", "Remove All Sections", ["Home", "Slides", "Section"], None, "{}", has_slide, section_remove_all), + cmd!("section.move", "Move Section", [], None, "{index, to}", has_slide, section_move), + cmd!("slide.fromOutline", "Slides from Outline…", ["Insert", "Slides From"], None, "{text}", has_doc, from_outline), + ] +} + +/// Resolve a layout by id, name or kind keyword. +pub fn find_layout(doc: &Presentation, key: &Value, master: usize) -> Option { + if let Some(n) = key.as_u64() { + let id = LayoutId(u32::try_from(n).ok()?); + if doc.layout(id).is_some() { + return Some(id); + } + } + let s = key.as_str()?; + let m = doc.masters.get(master).or(doc.masters.first())?; + let norm = |x: &str| x.chars().filter(|c| c.is_alphanumeric()).collect::().to_ascii_lowercase(); + let k = norm(s); + let kind = match k.as_str() { + "title" | "titleslide" => Some(LayoutType::Title), + "titleandcontent" | "content" | "obj" => Some(LayoutType::TitleAndContent), + "sectionheader" | "section" => Some(LayoutType::SectionHeader), + "twocontent" | "two" => Some(LayoutType::TwoContent), + "comparison" => Some(LayoutType::Comparison), + "titleonly" => Some(LayoutType::TitleOnly), + "blank" => Some(LayoutType::Blank), + "contentwithcaption" => Some(LayoutType::ContentWithCaption), + "picturewithcaption" | "picture" => Some(LayoutType::PictureWithCaption), + "titleandverticaltext" => Some(LayoutType::TitleAndVerticalText), + "verticaltitleandtext" => Some(LayoutType::VerticalTitleAndText), + _ => None, + }; + m.layouts.iter().find(|l| norm(&l.name) == k).or_else(|| kind.and_then(|kd| m.layouts.iter().find(|l| l.kind == kd))).map(|l| l.id) +} + +fn current_master(doc: &Presentation, sel: &crate::Selection) -> usize { + doc.slides.get(sel.slide).and_then(|s| doc.masters.iter().position(|m| m.layout(s.layout).is_some())).unwrap_or(0) +} + +fn new(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + let master = current_master(doc, sel); + let layout = match p.get("layout") { + Some(k) => find_layout(doc, k, master).ok_or_else(|| bad("slide.new", format!("no layout {k}")))?, + None => { + // PowerPoint picks Title and Content after a title slide, else the current layout. + let cur = doc.slides.get(sel.slide).map(|x| x.layout); + let is_title = cur.and_then(|l| doc.layout(l)).is_some_and(|(_, l)| l.kind == LayoutType::Title); + match cur { + Some(l) if !is_title => l, + _ => defaults::layout_of_kind(doc, LayoutType::TitleAndContent).or(cur).unwrap_or_default(), + } + } + }; + let mut slide = defaults::new_slide(doc, layout); + if let Some(t) = str_param(p, "title") + && let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())) + { + sh.text = Some(TextBody::from_text(t)); + } + if let Some(b) = str_param(p, "body") + && let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title())) + { + sh.text = Some(TextBody::from_text(b)); + } + let at = usize_param(p, "at").unwrap_or(if doc.slides.is_empty() { 0 } else { sel.slide + 1 }).min(doc.slides.len()); + let id = slide.id; + // Inherit the section of the slide before. + let section = at.checked_sub(1).and_then(|i| doc.slides.get(i)).and_then(|prev| doc.section_of(prev.id)); + doc.slides.insert(at, Arc::new(slide)); + if let Some(si) = section + && let Some(sec) = doc.sections.get_mut(si) + { + let pos = at + .checked_sub(1) + .and_then(|i| doc.slides.get(i)) + .and_then(|prev| sec.slides.iter().position(|x| *x == prev.id)) + .map(|k| k + 1) + .unwrap_or(sec.slides.len()); + sec.slides.insert(pos.min(sec.slides.len()), id); + } else if let Some(first) = doc.sections.first_mut() { + first.slides.insert(0, id); + } + sel.slide = at; + sel.slides = vec![id]; + sel.shapes.clear(); + sel.text = None; + sel.target = Target::Slides; + Ok(json!({"index": at, "id": id})) + }) +} + +fn index_of(s: &Session, p: &Value) -> Result { + let st = s.doc()?; + let i = usize_param(p, "index").unwrap_or(st.selection.slide); + if i >= st.doc.slides.len() { + return Err(bad("slide", format!("no slide {i}"))); + } + Ok(i) +} + +fn duplicate(s: &mut Session, p: &Value) -> Result { + let indices: Vec = match usize_param(p, "index") { + Some(i) => vec![i], + None => { + let st = s.doc()?; + let mut v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); + if v.is_empty() { + v.push(st.selection.slide); + } + v.sort_unstable(); + v + } + }; + s.edit(|doc, sel| { + let last = *indices.last().unwrap_or(&0); + let mut new_ids = vec![]; + for (k, i) in indices.iter().enumerate() { + let Some(src) = doc.slides.get(*i).map(|x| x.as_ref().clone()) else { continue }; + let mut copy = src; + copy.id = SlideId(doc.alloc_id()); + super::edit::reid(doc, &mut copy.shapes); + // Animations follow the new shape ids by position. + let old_ids: Vec<_> = doc + .slides + .get(*i) + .map(|x| { + let mut v = vec![]; + slidecraft_model::walk(&x.shapes, &mut |sh, _| v.push(sh.id)); + v + }) + .unwrap_or_default(); + let mut new_shape_ids = vec![]; + slidecraft_model::walk(©.shapes, &mut |sh, _| new_shape_ids.push(sh.id)); + for a in &mut copy.animations { + if let Some(pos) = old_ids.iter().position(|x| *x == a.shape) { + a.shape = new_shape_ids.get(pos).copied().unwrap_or(a.shape); + } + } + let section = doc.section_of(src_id(doc, *i)); + let at = (last + 1 + k).min(doc.slides.len()); + new_ids.push(copy.id); + let nid = copy.id; + doc.slides.insert(at, Arc::new(copy)); + if let Some(si) = section + && let Some(sec) = doc.sections.get_mut(si) + { + sec.slides.push(nid); + } + } + sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1)); + sel.slides = new_ids.clone(); + sel.shapes.clear(); + sel.text = None; + Ok(json!({"ids": new_ids})) + }) +} + +fn src_id(doc: &Presentation, i: usize) -> SlideId { + doc.slides.get(i).map(|s| s.id).unwrap_or_default() +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let ids: Vec = { + let st = s.doc()?; + if let Some(a) = p.get("ids").and_then(Value::as_array) { + a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect() + } else if let Some(i) = usize_param(p, "index") { + st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default() + } else if !st.selection.slides.is_empty() { + st.selection.slides.clone() + } else { + st.current_slide().map(|x| vec![x.id]).unwrap_or_default() + } + }; + if ids.is_empty() { + return Err(bad("slide.delete", "no such slide")); + } + s.edit(|doc, sel| { + let first = ids.iter().filter_map(|id| doc.slide_index(*id)).min().unwrap_or(0); + doc.slides.retain(|x| !ids.contains(&x.id)); + for sec in &mut doc.sections { + sec.slides.retain(|x| !ids.contains(x)); + } + sel.slide = first.min(doc.slides.len().saturating_sub(1)); + sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default(); + sel.shapes.clear(); + sel.text = None; + Ok(json!({"deleted": ids.len(), "remaining": doc.slides.len()})) + }) +} + +fn move_slide(s: &mut Session, p: &Value) -> Result { + let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide); + let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?; + s.edit(|doc, sel| { + if from >= doc.slides.len() { + return Err(bad("slide.move", format!("no slide {from}"))); + } + let sl = doc.slides.remove(from); + let to = to.min(doc.slides.len()); + let id = sl.id; + doc.slides.insert(to, sl); + // Keep sections consistent with order: move the id into the section of its new neighbour. + if !doc.sections.is_empty() { + for sec in &mut doc.sections { + sec.slides.retain(|x| *x != id); + } + let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id); + let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0); + let order: Vec = doc.slides.iter().map(|x| x.id).collect(); + if let Some(sec) = doc.sections.get_mut(si) { + sec.slides.push(id); + sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX)); + } + } + sel.slide = to; + sel.slides = vec![id]; + Ok(json!({"index": to})) + }) +} + +fn go_to(s: &mut Session, i: usize) -> Result { + s.select(|doc, sel| { + let n = doc.slides.len(); + sel.slide = i.min(n.saturating_sub(1)); + sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default(); + sel.shapes.clear(); + sel.text = None; + sel.cells = None; + sel.target = Target::Slides; + })?; + Ok(json!({"index": s.doc()?.selection.slide})) +} + +fn go(s: &mut Session, p: &Value) -> Result { + let i = match (usize_param(p, "index"), p.get("id").and_then(Value::as_u64)) { + (Some(i), _) => i, + (None, Some(id)) => s.doc()?.doc.slide_index(SlideId(u32::try_from(id).unwrap_or(0))).ok_or_else(|| bad("slide.go", "no such slide"))?, + _ => return Err(bad("slide.go", "missing `index`")), + }; + go_to(s, i) +} +fn next(s: &mut Session, _p: &Value) -> Result { + let i = s.doc()?.selection.slide + 1; + go_to(s, i) +} +fn prev(s: &mut Session, _p: &Value) -> Result { + let i = s.doc()?.selection.slide.saturating_sub(1); + go_to(s, i) +} +fn first(s: &mut Session, _p: &Value) -> Result { + go_to(s, 0) +} +fn last(s: &mut Session, _p: &Value) -> Result { + let n = s.doc()?.doc.slides.len(); + go_to(s, n.saturating_sub(1)) +} + +fn select_slides(s: &mut Session, p: &Value) -> Result { + let add = bool_or(p, "add", false); + let ids: Vec = { + let st = s.doc()?; + let mut v: Vec = p + .get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).filter_map(|x| u32::try_from(x).ok()).map(SlideId).collect()) + .unwrap_or_default(); + if let Some(a) = p.get("indices").and_then(Value::as_array) { + v.extend(a.iter().filter_map(Value::as_u64).filter_map(|i| st.doc.slides.get(i as usize)).map(|x| x.id)); + } + v + }; + s.select(|doc, sel| { + if add { + for i in &ids { + if !sel.slides.contains(i) { + sel.slides.push(*i); + } + } + } else { + sel.slides = ids.clone(); + } + if let Some(last) = ids.last().and_then(|i| doc.slide_index(*i)) { + sel.slide = last; + } + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"selected": s.doc()?.selection.slides})) +} + +fn hide(s: &mut Session, p: &Value) -> Result { + let targets: Vec = match usize_param(p, "index") { + Some(i) => vec![i], + None => { + let st = s.doc()?; + let v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); + if v.is_empty() { vec![st.selection.slide] } else { v } + } + }; + s.edit(|doc, _| { + let all_hidden = targets.iter().all(|i| doc.slides.get(*i).is_some_and(|x| x.hidden)); + let value = bool_param(p, "hidden").unwrap_or(!all_hidden); + for i in &targets { + if let Some(sl) = doc.slides.get_mut(*i) { + Arc::make_mut(sl).hidden = value; + } + } + Ok(json!({"hidden": value})) + }) +} + +/// Re-map a slide's placeholders onto a new layout (content kept, matched by type/index). +pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> Result<()> { + let lay_shapes: Vec = + doc.layout(layout).map(|(_, l)| l.shapes.clone()).ok_or_else(|| bad("slide.layout", "no such layout"))?; + let mut fresh = vec![]; + for ls in lay_shapes.iter().filter(|x| x.ph.as_ref().is_some_and(|p| !p.kind.is_footer_kind())) { + let id = slidecraft_model::ShapeId(doc.alloc_id()); + fresh.push((id, ls.clone())); + } + let slide = doc.slides.get_mut(index).ok_or_else(|| bad("slide.layout", "no slide"))?; + let slide = Arc::make_mut(slide); + let mut old: Vec = std::mem::take(&mut slide.shapes); + let mut out = vec![]; + for (id, ls) in fresh { + let Some(lph) = ls.ph.clone() else { continue }; + // Find matching old placeholder: same idx, else same title-ness/compatible type. + let k = old + .iter() + .position(|o| o.ph.as_ref().is_some_and(|p| p.kind == lph.kind && (p.idx == lph.idx || lph.kind.is_title()))) + .or_else(|| old.iter().position(|o| o.ph.as_ref().is_some_and(|p| p.kind.is_title() == lph.kind.is_title() && !p.kind.is_footer_kind()))); + match k { + Some(k) => { + let mut o = old.remove(k); + if let Some(p) = o.ph.as_mut() { + p.kind = lph.kind; + p.idx = lph.idx; + } + o.xfrm = None; + out.push(o); + } + None => { + out.push(slidecraft_model::Shape { id, name: ls.name.clone(), ph: Some(lph), text: Some(TextBody::default()), ..Default::default() }) + } + } + } + // Leftover placeholders with content stay as they were (positioned); empty ones go. + for o in old { + let empty_ph = o.ph.is_some() && o.text.as_ref().is_none_or(|t| t.is_empty()) && matches!(o.kind, slidecraft_model::ShapeKind::Shape); + if !empty_ph { + out.push(o); + } + } + slide.shapes = out; + slide.layout = layout; + Ok(()) +} + +fn set_layout(s: &mut Session, p: &Value) -> Result { + let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?; + let targets: Vec = match usize_param(p, "index") { + Some(i) => vec![i], + None => { + let st = s.doc()?; + let v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); + if v.is_empty() { vec![st.selection.slide] } else { v } + } + }; + s.edit(|doc, sel| { + let master = current_master(doc, sel); + let layout = find_layout(doc, &key, master).ok_or_else(|| bad("slide.layout", format!("no layout {key}")))?; + for i in &targets { + apply_layout(doc, *i, layout)?; + } + sel.shapes.clear(); + sel.text = None; + Ok(json!({"layout": layout})) + }) +} + +fn reset(s: &mut Session, p: &Value) -> Result { + let i = index_of(s, p)?; + s.edit(|doc, sel| { + let layout = doc.slides.get(i).map(|x| x.layout).unwrap_or_default(); + apply_layout(doc, i, layout)?; + if let Some(sl) = doc.slides.get_mut(i) { + let sl = Arc::make_mut(sl); + sl.background = None; + for sh in &mut sl.shapes { + if sh.ph.is_some() { + sh.xfrm = None; + sh.fill = None; + sh.line = None; + sh.effects = None; + sh.geom = Default::default(); + if let Some(t) = sh.text.as_mut() { + t.body = Default::default(); + t.list_style = Default::default(); + for para in &mut t.paragraphs { + para.props = Default::default(); + for r in &mut para.runs { + r.props = Default::default(); + } + } + } + } + } + } + sel.shapes.clear(); + Ok(()) + })?; + ok() +} + +fn notes(s: &mut Session, p: &Value) -> Result { + let i = index_of(s, p)?; + let text = str_param(p, "text").unwrap_or("").to_string(); + s.edit(|doc, _| { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).notes = TextBody::from_text(&text); + } + Ok(()) + })?; + ok() +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let i = index_of(s, p)?; + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|doc, _| { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).name = name; + } + Ok(()) + })?; + ok() +} + +fn section_add(s: &mut Session, p: &Value) -> Result { + let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide); + let name = str_param(p, "name").unwrap_or("Untitled Section").to_string(); + s.edit(|doc, _| { + let at = at.min(doc.slides.len().saturating_sub(1)); + let ids: Vec = doc.slides.iter().map(|x| x.id).collect(); + if doc.sections.is_empty() { + // The slides before `at` go into a "Default Section". + if at > 0 { + doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() }); + } + doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() }); + } else { + let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) }; + let si = doc.section_of(start).unwrap_or(0); + let sec = doc.sections.get_mut(si).ok_or_else(|| bad("section.add", "no section"))?; + let k = sec.slides.iter().position(|x| *x == start).unwrap_or(sec.slides.len()); + let tail = sec.slides.split_off(k); + doc.sections.insert(si + 1, Section { name, slides: tail }); + doc.sections.retain(|x| !x.slides.is_empty() || x.name != "Default Section"); + } + Ok(json!({"sections": doc.sections.len()})) + }) +} + +fn section_rename(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad("section.rename", "missing `index`"))?; + let name = str_param(p, "name").unwrap_or("").to_string(); + s.edit(|doc, _| { + let sec = doc.sections.get_mut(i).ok_or_else(|| bad("section.rename", "no such section"))?; + sec.name = name; + Ok(()) + })?; + ok() +} + +fn section_remove(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad("section.remove", "missing `index`"))?; + let with_slides = bool_or(p, "slides", false); + s.edit(|doc, sel| { + if i >= doc.sections.len() { + return Err(bad("section.remove", "no such section")); + } + let sec = doc.sections.remove(i); + if with_slides { + doc.slides.retain(|x| !sec.slides.contains(&x.id)); + sel.slide = sel.slide.min(doc.slides.len().saturating_sub(1)); + } else if let Some(prev) = i.checked_sub(1).and_then(|k| doc.sections.get_mut(k)) { + prev.slides.extend(sec.slides); + } else if let Some(next) = doc.sections.get_mut(0) { + let mut v = sec.slides; + v.append(&mut next.slides); + next.slides = v; + } + Ok(()) + })?; + ok() +} + +fn section_remove_all(s: &mut Session, _p: &Value) -> Result { + s.edit(|doc, _| { + doc.sections.clear(); + Ok(()) + })?; + ok() +} + +fn section_move(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").ok_or_else(|| bad("section.move", "missing `index`"))?; + let to = usize_param(p, "to").ok_or_else(|| bad("section.move", "missing `to`"))?; + s.edit(|doc, _| { + if i >= doc.sections.len() { + return Err(bad("section.move", "no such section")); + } + let sec = doc.sections.remove(i); + let to = to.min(doc.sections.len()); + doc.sections.insert(to, sec); + // Reorder slides to follow sections. + let order: Vec = doc.sections.iter().flat_map(|x| x.slides.iter().copied()).collect(); + doc.slides.sort_by_key(|x| order.iter().position(|o| *o == x.id).unwrap_or(usize::MAX)); + Ok(()) + })?; + ok() +} + +fn from_outline(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").unwrap_or("").to_string(); + s.edit(|doc, sel| { + let before = doc.slides.len(); + let n = slidecraft_format::outline_to_slides(doc, &text); + // outline_to_slides appends; move them after the current slide. + let at = (sel.slide + 1).min(before); + let new: Vec<_> = doc.slides.drain(before..).collect(); + for (k, sl) in new.into_iter().enumerate() { + doc.slides.insert(at + k, sl); + } + Ok(json!({"slides": n})) + }) +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs new file mode 100644 index 0000000..fb8bcfa --- /dev/null +++ b/crates/engine/src/cmd/table.rs @@ -0,0 +1,269 @@ +//! Table Design and Table Layout tabs. + +use serde_json::{Value, json}; +use slidecraft_model::{ShapeKind, Table}; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!("table.insertRowAbove", "Insert Above", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols( + s, p, "rowAbove" + )), + cmd!("table.insertRowBelow", "Insert Below", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols( + s, p, "rowBelow" + )), + cmd!("table.insertColumnLeft", "Insert Left", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols( + s, p, "colLeft" + )), + cmd!("table.insertColumnRight", "Insert Right", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols( + s, p, "colRight" + )), + cmd!("table.deleteRow", "Delete Rows", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols( + s, p, "delRow" + )), + cmd!("table.deleteColumn", "Delete Columns", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols( + s, p, "delCol" + )), + cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge), + cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split), + cmd!( + "table.style", + "Table Styles", + ["Table Design", "Table Styles"], + None, + "{style: medium2-accent1|light1-accent2|dark1-tx1|grid|none…, id?}", + has_selection, + style + ), + cmd!( + "table.options", + "Table Style Options", + ["Table Design", "Table Style Options"], + None, + "{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}", + has_selection, + options + ), + cmd!( + "table.cellFill", + "Shading", + ["Table Design", "Table Styles"], + None, + "{color? | none?, cells?: [[r,c],…] (default: selected cells or all), id?}", + has_selection, + cell_fill + ), + cmd!("table.distributeRows", "Distribute Rows", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(s, p, true)), + cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute( + s, p, false + )), + cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width), + cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height), + cmd!(noundo "table.selectCells", "Select Cells", [], None, "{from: [r,c], to: [r,c], id?}", has_selection, select_cells), + ] +} + +fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> { + let a = p.get(key)?.as_array()?; + Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) +} + +/// The table's row/col to act on: params, the edited cell, or the selected cells. +fn at(s: &Session, p: &Value) -> (usize, usize) { + let st = s.active(); + let cell = st.and_then(|d| d.selection.text.as_ref().and_then(|t| t.cell)).or_else(|| st.and_then(|d| d.selection.cells.map(|c| c.1))); + (usize_param(p, "row").or(cell.map(|c| c.0)).unwrap_or(0), usize_param(p, "col").or(cell.map(|c| c.1)).unwrap_or(0)) +} + +fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> Result<()>) -> Result { + let id = match id_param(p, "id") { + Some(i) => i, + None => { + let st = s.doc()?; + st.selection + .text + .as_ref() + .filter(|t| t.cell.is_some()) + .map(|t| t.shape) + .or(st.selection.cells.map(|c| c.0)) + .or_else(|| st.selection.shapes.first().copied()) + .ok_or_else(|| bad(cmd, "select a table"))? + } + }; + let r = edit_shapes(s, &json!({"id": id}), cmd, |sh| match &mut sh.kind { + ShapeKind::Table(t) => { + f(t)?; + t.normalize(); + let (w, h) = (t.width(), t.height()); + if let Some(x) = sh.xfrm.as_mut() { + x.w = w; + x.h = h; + } + Ok(()) + } + _ => Err(bad(cmd, "not a table")), + })?; + // The edited cell may have moved out of range. + s.select(|doc, sel| { + let dims = super::current_shapes(doc, sel) + .and_then(|v| slidecraft_model::find_shape(v, id)) + .and_then(|x| if let ShapeKind::Table(t) = &x.kind { Some((t.n_rows(), t.n_cols())) } else { None }); + if let (Some(t), Some((nr, nc))) = (sel.text.as_mut(), dims) + && let Some((r, c)) = t.cell + && (r >= nr || c >= nc) + { + t.cell = Some((r.min(nr.saturating_sub(1)), c.min(nc.saturating_sub(1)))); + t.anchor = (0, 0); + t.caret = (0, 0); + } + })?; + Ok(r) +} + +fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result { + let (r, c) = at(s, p); + let op = op.to_string(); + with_table(s, p, "table", move |t| { + match op.as_str() { + "rowAbove" => t.insert_row(r), + "rowBelow" => t.insert_row(r + 1), + "colLeft" => t.insert_col(c), + "colRight" => t.insert_col(c + 1), + "delRow" => { + if !t.delete_row(r) { + return Err(bad("table.deleteRow", "can't delete the last row")); + } + } + _ => { + if !t.delete_col(c) { + return Err(bad("table.deleteColumn", "can't delete the last column")); + } + } + } + Ok(()) + }) +} + +fn merge(s: &mut Session, p: &Value) -> Result { + let sel = s.doc()?.selection.cells; + let from = cell_param(p, "from").or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?; + let to = cell_param(p, "to").or(sel.map(|c| c.2)).ok_or_else(|| bad("table.merge", "missing `to`"))?; + with_table( + s, + p, + "table.merge", + move |t| if t.merge(from.0, from.1, to.0, to.1) { Ok(()) } else { Err(bad("table.merge", "can't merge those cells")) }, + ) +} + +fn split(s: &mut Session, p: &Value) -> Result { + let cell = cell_param(p, "cell").unwrap_or_else(|| at(s, p)); + with_table(s, p, "table.split", move |t| if t.split(cell.0, cell.1) { Ok(()) } else { Err(bad("table.split", "that cell isn't merged")) }) +} + +fn style(s: &mut Session, p: &Value) -> Result { + let st = str_param(p, "style").ok_or_else(|| bad("table.style", "missing `style`"))?.to_string(); + with_table(s, p, "table.style", move |t| { + t.style = st.clone(); + Ok(()) + }) +} + +fn options(s: &mut Session, p: &Value) -> Result { + with_table(s, p, "table.options", |t| { + if let Some(v) = bool_param(p, "headerRow") { + t.first_row = v; + } + if let Some(v) = bool_param(p, "totalRow") { + t.last_row = v; + } + if let Some(v) = bool_param(p, "bandedRows") { + t.band_row = v; + } + if let Some(v) = bool_param(p, "firstColumn") { + t.first_col = v; + } + if let Some(v) = bool_param(p, "lastColumn") { + t.last_col = v; + } + if let Some(v) = bool_param(p, "bandedColumns") { + t.band_col = v; + } + Ok(()) + }) +} + +fn cell_fill(s: &mut Session, p: &Value) -> Result { + let fill = + if bool_or(p, "none", false) { Some(slidecraft_model::Fill::None) } else { color_param(p, "color").map(slidecraft_model::Fill::solid) }; + let cells: Option> = p + .get("cells") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect()); + let sel = s.doc()?.selection.cells; + with_table(s, p, "table.cellFill", move |t| { + let list: Vec<(usize, usize)> = match (&cells, sel) { + (Some(c), _) => c.clone(), + (None, Some((_, a, b))) => (a.0.min(b.0)..=a.0.max(b.0)).flat_map(|r| (a.1.min(b.1)..=a.1.max(b.1)).map(move |c| (r, c))).collect(), + _ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(), + }; + for (r, c) in list { + if let Some(cell) = t.cell_mut(r, c) { + cell.fill.clone_from(&fill); + } + } + Ok(()) + }) +} + +fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result { + with_table(s, p, "table.distribute", move |t| { + if rows { + let h = t.height() / t.n_rows().max(1) as f64; + t.rows.iter_mut().for_each(|r| r.height = h); + } else { + let w = t.width() / t.n_cols().max(1) as f64; + t.cols.iter_mut().for_each(|c| *c = w); + } + Ok(()) + }) +} + +fn column_width(s: &mut Session, p: &Value) -> Result { + let (_, c) = at(s, p); + let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0); + with_table(s, p, "table.columnWidth", move |t| { + if let Some(x) = t.cols.get_mut(c) { + *x = w; + } + Ok(()) + }) +} + +fn row_height(s: &mut Session, p: &Value) -> Result { + let (r, _) = at(s, p); + let h = f64_param(p, "height").ok_or_else(|| bad("table.rowHeight", "missing `height`"))?.clamp(4.0, 4000.0); + with_table(s, p, "table.rowHeight", move |t| { + if let Some(x) = t.rows.get_mut(r) { + x.height = h; + } + Ok(()) + }) +} + +fn select_cells(s: &mut Session, p: &Value) -> Result { + let id = match id_param(p, "id") { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("table.selectCells", "select a table"))?, + }; + let from = cell_param(p, "from").unwrap_or((0, 0)); + let to = cell_param(p, "to").unwrap_or(from); + s.select(|_, sel| { + sel.cells = Some((id, from, to)); + sel.shapes = vec![id]; + sel.text = None; + })?; + ok() +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs new file mode 100644 index 0000000..751251b --- /dev/null +++ b/crates/engine/src/cmd/text.rs @@ -0,0 +1,659 @@ +//! In-place text editing: entering/leaving text, typing, deleting, caret movement and selection. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_model::edit::{self as ed, Pos}; +use slidecraft_model::resolve::{self, Ctx}; +use slidecraft_model::text::{AutoFit, Run, RunKind, RunProps, TextBody}; +use slidecraft_model::{Presentation, ShapeId, ShapeKind}; + +use super::*; +use crate::{DocState, Result, Selection, Session, Target, TextSel}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "text.edit", "Edit Text", [], Some("Enter"), "{id?, at?: [paragraph, char], end?: bool, cell?: [row, col], notes?: bool}", has_slide, edit), + cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit), + cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert), + cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete), + cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|start|end, extend?: bool}", has_text, move_caret), + cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select), + cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all), + cmd!(noundo "text.selectWord", "Select Word", [], None, "{at?: [p, c]}", has_text, select_word), + cmd!(noundo "text.selectParagraph", "Select Paragraph", [], None, "{at?: [p, c]}", has_text, select_para), + cmd!( + "text.set", + "Set Text", + [], + None, + "{id?, text, cell?: [r, c]} — replaces the whole text, keeping the first run's formatting", + has_slide, + set + ), + cmd!(query "text.get", "Get Text", [], None, "{id?} → {text, paragraphs}", has_slide, get), + ] +} + +/// The text body a text selection edits. +pub fn body_of<'a>(st: &'a DocState, t: &TextSel) -> Option<&'a TextBody> { + if t.notes { + return st.current_slide().map(|s| &s.notes); + } + let sh = st.shape(t.shape)?; + if let (Some((r, c)), ShapeKind::Table(tb)) = (t.cell, &sh.kind) { + return tb.cell(r, c).map(|x| &x.text); + } + sh.text.as_ref() +} + +fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Option<&'a mut TextBody> { + if t.notes { + return doc.slides.get_mut(sel.slide).map(|s| &mut Arc::make_mut(s).notes); + } + let shapes = crate::shapes_mut(doc, sel)?; + let sh = slidecraft_model::find_shape_mut(shapes, t.shape)?; + if let Some((r, c)) = t.cell { + if let ShapeKind::Table(tb) = &mut sh.kind { + return tb.cell_mut(r, c).map(|x| &mut x.text); + } + return None; + } + Some(sh.text.get_or_insert_with(TextBody::default)) +} + +/// Edit the active text body and selection; refits auto-sizing text boxes afterwards. +pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { + let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?; + // Materialise an inherited placeholder box before editing (so autofit can change it). + let r = s.edit(|doc, sel| { + let mut t = t0.clone(); + let body = body_mut(doc, sel, &t).ok_or_else(|| bad("text", "the text being edited is gone"))?; + let r = f(body, &mut t); + sel.text = Some(t); + Ok(r) + })?; + if !t0.notes && t0.cell.is_none() { + fit_text_box(s, t0.shape)?; + } + Ok(r) +} + +/// Grow/shrink an auto-sizing text box (Resize shape to fit text) to its text. +pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> { + let st = s.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(()) }; + let Some(body) = sh.text.clone() else { return Ok(()) }; + let doc = st.doc.clone(); + let sel = st.selection.clone(); + let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; + let bp = resolve::body(&ctx, &sh); + if bp.autofit != Some(AutoFit::Shape) { + return Ok(()); + } + let x = xfrm_of(&doc, &sel, &sh); + let geo = slidecraft_render::shape_geometry(&sh, x.w, x.h); + let fields = slidecraft_text::NoFields; + let mut nx = x; + if bp.wrap == Some(false) { + nx.w = slidecraft_text::fit_width(&ctx, &sh, &body, geo.text_rect, &fields).max(20.0); + } + let geo = slidecraft_render::shape_geometry(&sh, nx.w, nx.h); + let need = slidecraft_text::fit_height(&ctx, &sh, &body, geo.text_rect, &fields); + let extra = (nx.h - geo.text_rect.height()).max(0.0); + nx.h = (need + extra).max(8.0); + if (nx.h - x.h).abs() < 0.01 && (nx.w - x.w).abs() < 0.01 && sh.xfrm.is_some() { + return Ok(()); + } + // Change the box in place without an extra undo step (it is part of the edit). + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + if let Some(list) = crate::shapes_mut(&mut d, &st.selection) + && let Some(target) = slidecraft_model::find_shape_mut(list, id) + { + target.xfrm = Some(nx); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + +pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option> { + match sel.target { + Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)), + Target::Master { master } => doc.masters.get(master).map(|m| Ctx::for_master(doc, m)), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), + } +} + +/// Lay out the text being edited (for vertical caret movement and hit testing). +pub fn layout_for(st: &DocState, t: &TextSel) -> Option { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let body = sh.text.as_ref()?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let geo = slidecraft_render::shape_geometry(sh, x.w, x.h); + Some(slidecraft_text::layout( + &ctx, + sh, + body, + &slidecraft_text::Opts { rect: geo.text_rect, fields: &slidecraft_text::NoFields, prompt_color: None, no_shrink: false }, + )) +} + +fn pos_param(p: &Value, key: &str) -> Option { + let a = p.get(key)?.as_array()?; + Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) +} + +fn edit(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "notes", false) { + let end = s.doc()?.current_slide().map(|x| ed::end(&x.notes)).unwrap_or((0, 0)); + s.select(|_, sel| { + sel.text = Some(TextSel { notes: true, anchor: end, caret: end, ..Default::default() }); + sel.shapes.clear(); + })?; + return ok(); + } + let id = match id_param(p, "id") { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, + }; + let sh = shape_of(s, id)?; + let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + let body = match (&sh.kind, cell) { + (ShapeKind::Table(t), Some((r, c))) => t.cell(r, c).map(|x| x.text.clone()).ok_or_else(|| bad("text.edit", "no such cell"))?, + (ShapeKind::Table(_), None) => return Err(bad("text.edit", "say which `cell` of the table")), + (ShapeKind::Shape | ShapeKind::Connector { .. }, _) => sh.text.clone().unwrap_or_default(), + _ => return Err(bad("text.edit", "this object doesn't hold text")), + }; + let at = pos_param(p, "at").unwrap_or_else(|| ed::end(&body)); + // A shape without a text body gets one on first edit. + if sh.text.is_none() && cell.is_none() { + s.edit(|doc, sel| { + if let Some(list) = crate::shapes_mut(doc, sel) + && let Some(x) = slidecraft_model::find_shape_mut(list, id) + { + x.text = Some(TextBody::default()); + } + Ok(()) + })?; + } + s.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false }); + })?; + Ok(json!({"id": id, "at": [at.0, at.1]})) +} + +fn exit(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + if let Some(t) = sel.text.take() + && !t.notes + { + sel.shapes = vec![t.shape]; + } + })?; + ok() +} + +/// Smart quotes and simple autocorrect applied to typed text. +fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String { + if !s.prefs.smart_quotes || text.chars().count() != 1 { + return text.to_string(); + } + let prev = body.paragraphs.get(at.0).and_then(|p| p.text().chars().nth(at.1.wrapping_sub(1))); + let opening = prev.is_none_or(|c| c.is_whitespace() || "([{".contains(c)); + match text { + "\"" => (if opening { "“" } else { "”" }).to_string(), + "'" => (if opening { "‘" } else { "’" }).to_string(), + _ => text.to_string(), + } +} + +pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) -> Result { + let text = { + let st = s.doc()?; + match &st.selection.text { + Some(t) => { + let body = body_of(st, t).cloned().unwrap_or_default(); + typed(s, &body, t.ordered().0, text) + } + None => text.to_string(), + } + }; + let pos = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = props.clone().or_else(|| if a != b { Some(ed::props_at(body, at)) } else { None }); + let c = ed::insert(body, at, &text, props); + t.anchor = c; + t.caret = c; + c + })?; + autocorrect(s)?; + Ok(json!({"caret": [pos.0, pos.1]})) +} + +const AUTOCORRECT: &[(&str, &str)] = &[ + ("teh", "the"), + ("adn", "and"), + ("recieve", "receive"), + ("seperate", "separate"), + ("occured", "occurred"), + ("thier", "their"), + ("wich", "which"), + ("(c)", "©"), + ("(r)", "®"), + ("(tm)", "™"), + ("->", "→"), + ("<-", "←"), + ("--", "–"), +]; + +/// After a space or punctuation, replace a just-typed autocorrect entry. +fn autocorrect(s: &mut Session) -> Result<()> { + if !s.prefs.autocorrect { + return Ok(()); + } + let st = s.doc()?; + let Some(t) = st.selection.text.clone() else { return Ok(()) }; + let Some(body) = body_of(st, &t) else { return Ok(()) }; + let line: Vec = body.paragraphs.get(t.caret.0).map(|p| p.text().chars().collect()).unwrap_or_default(); + let end = t.caret.1; + if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) { + return Ok(()); + } + let word_end = end - 1; + let mut start = word_end; + while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { + start -= 1; + } + let word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); + let Some((_, rep)) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(&word)) else { return Ok(()) }; + let rep = if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(rep, "sentence") } else { rep.to_string() }; + let delta = rep.chars().count() as isize - word.chars().count() as isize; + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + let sel = st.selection.clone(); + if let Some(b) = body_mut(&mut d, &sel, &t) { + ed::delete(b, (t.caret.0, start), (t.caret.0, word_end)); + ed::insert(b, (t.caret.0, start), &rep, None); + } + st.doc = Arc::new(d); + let c = (t.caret.0, (end as isize + delta).max(0) as usize); + if let Some(tt) = st.selection.text.as_mut() { + tt.caret = c; + tt.anchor = c; + } + st.revision += 1; + Ok(()) +} + +pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { + let pos = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let c = ed::insert_body(body, at, src); + t.anchor = c; + t.caret = c; + c + })?; + Ok(json!({"caret": [pos.0, pos.1]})) +} + +pub(crate) fn insert_field(s: &mut Session, field: &str, text: &str) -> Result { + let field = field.to_string(); + let text = if text.is_empty() { "#".to_string() } else { text.to_string() }; + edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = ed::props_at(body, at); + let src = TextBody { + paragraphs: vec![slidecraft_model::text::Paragraph { + runs: vec![Run { text: text.clone(), props, kind: RunKind::Field { field: field.clone() } }], + ..Default::default() + }], + ..Default::default() + }; + let c = ed::insert_body(body, at, &src); + t.anchor = c; + t.caret = c; + })?; + ok() +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); + insert_text(s, &text, None) +} + +pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { + let dir = dir.to_string(); + edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = if a != b { + ed::delete(body, a, b) + } else { + let other = match dir.as_str() { + "forward" => ed::char_move(body, a, true), + "wordBackward" => ed::word_move(body, a, false), + "wordForward" => ed::word_move(body, a, true), + _ => { + // Backspace at the start of an indented/bulleted paragraph outdents first. + if a.1 == 0 + && let Some(para) = body.paragraphs.get_mut(a.0) + && para.level > 0 + { + para.level -= 1; + t.anchor = a; + t.caret = a; + return; + } + ed::char_move(body, a, false) + } + }; + ed::delete(body, a, other) + }; + t.anchor = at; + t.caret = at; + })?; + ok() +} + +fn delete(s: &mut Session, p: &Value) -> Result { + delete_dir(s, str_param(p, "dir").unwrap_or("backward")) +} + +fn move_caret(s: &mut Session, p: &Value) -> Result { + let to = str_param(p, "to").unwrap_or("right").to_string(); + let extend = bool_or(p, "extend", false); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.move", "not editing text"))?; + let body = body_of(st, &t).cloned().unwrap_or_default(); + let layout = layout_for(st, &t); + let lp = |c: Pos| slidecraft_text::Pos { para: c.0, ch: c.1 }; + let caret = t.caret; + let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 }; + let new = match to.as_str() { + "left" if t.is_range() && !extend => collapse_to(true), + "right" if t.is_range() && !extend => collapse_to(false), + "left" => ed::char_move(&body, caret, false), + "right" => ed::char_move(&body, caret, true), + "wordLeft" => ed::word_move(&body, caret, false), + "wordRight" => ed::word_move(&body, caret, true), + "paraStart" => (caret.0, 0), + "paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), + "start" => (0, 0), + "end" => ed::end(&body), + "up" | "down" | "lineStart" | "lineEnd" => match &layout { + Some(l) => { + let x = l.caret(lp(caret)).map(|c| c.0).unwrap_or(0.0); + let r = match to.as_str() { + "up" => l.vertical(lp(caret), false, x), + "down" => l.vertical(lp(caret), true, x), + "lineStart" => l.line_bounds(lp(caret)).0, + _ => l.line_bounds(lp(caret)).1, + }; + (r.para, r.ch) + } + None => match to.as_str() { + "up" => (caret.0.saturating_sub(1), caret.1), + "down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1), + "lineStart" => (caret.0, 0), + _ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), + }, + }, + other => return Err(bad("text.move", format!("unknown direction `{other}`"))), + }; + let new = (new.0.min(body.paragraphs.len().saturating_sub(1)), new.1.min(body.paragraphs.get(new.0).map(|x| x.char_len()).unwrap_or(0))); + s.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.caret = new; + if !extend { + tt.anchor = new; + } + } + })?; + Ok(json!({"caret": [new.0, new.1]})) +} + +fn select(s: &mut Session, p: &Value) -> Result { + let a = pos_param(p, "anchor").ok_or_else(|| bad("text.select", "missing `anchor`"))?; + let c = pos_param(p, "caret").unwrap_or(a); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = a; + t.caret = c; + } + })?; + ok() +} + +fn select_all(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.selectAll", "not editing text"))?; + let end = body_of(st, &t).map(ed::end).unwrap_or((0, 0)); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = (0, 0); + t.caret = end; + } + })?; + ok() +} + +fn select_word(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.selectWord", "not editing text"))?; + let at = pos_param(p, "at").unwrap_or(t.caret); + let body = body_of(st, &t).cloned().unwrap_or_default(); + let (a, b) = ed::word_at(&body, at); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = a; + t.caret = b; + } + })?; + ok() +} + +fn select_para(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?; + let at = pos_param(p, "at").unwrap_or(t.caret); + let n = body_of(st, &t).and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = (at.0, 0); + t.caret = (at.0, n); + } + })?; + ok() +} + +fn set(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string(); + let id = match id_param(p, "id") { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, + }; + let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + edit_shapes(s, &json!({"id": id}), "text.set", |sh| { + let target = match (&mut sh.kind, cell) { + (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), + (ShapeKind::Table(_), None) => None, + _ => Some(sh.text.get_or_insert_with(TextBody::default)), + }; + let Some(body) = target else { return Err(bad("text.set", "no text here (tables need `cell`)")) }; + let keep = body.paragraphs.first().and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + let levels: Vec<(u8, slidecraft_model::text::ParaProps)> = body.paragraphs.iter().map(|p| (p.level, p.props.clone())).collect(); + let mut nb = TextBody::from_text(&text); + for (i, para) in nb.paragraphs.iter_mut().enumerate() { + // Leading tabs set the outline level. + let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); + if tabs > 0 + && let Some(r) = para.runs.first_mut() + { + r.text = r.text.trim_start_matches('\t').to_string(); + para.level = tabs.min(8) as u8; + } else if let Some((lv, pp)) = levels.get(i) { + para.level = *lv; + para.props = pp.clone(); + } + for r in &mut para.runs { + r.props = keep.clone(); + } + } + body.paragraphs = nb.paragraphs; + Ok(()) + })?; + fit_text_box(s, id)?; + ok() +} + +fn get(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = match id_param(p, "id") { + Some(i) => i, + None => match &st.selection.text { + Some(t) => t.shape, + None => st.selection.shapes.first().copied().ok_or_else(|| bad("text.get", "missing `id`"))?, + }, + }; + let sh = st.shape(id).ok_or_else(|| bad("text.get", "no such shape"))?; + let t = sh.text.clone().unwrap_or_default(); + Ok(json!({"text": t.text(), "paragraphs": t.paragraphs.iter().map(|p| json!({"level": p.level, "text": p.text()})).collect::>()})) +} + +/// Apply run formatting to the text selection (or, when not editing, to all text of the +/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), +/// or sets the formatting for what is typed next at a word boundary. +pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result { + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let body = body_of(st, &t).cloned().unwrap_or_default(); + let (mut a, mut b) = t.ordered(); + if a == b { + let (wa, wb) = ed::word_at(&body, a); + let inside = wa.1 < a.1 && a.1 < wb.1; + if inside { + a = wa; + b = wb; + // Don't include the trailing space. + let line: Vec = body.paragraphs.get(b.0).map(|p| p.text().chars().collect()).unwrap_or_default(); + while b.1 > a.1 && line.get(b.1 - 1) == Some(&' ') { + b.1 -= 1; + } + } + } + let collapsed = a == b; + edit_text(s, |body, _| { + if collapsed { + // Next typed text: put the props on an empty paragraph's end mark or keep pending. + ed::format(body, a, b, f); + } else { + ed::format(body, a, b, f); + } + })?; + if collapsed { + // Remember as pending typing props by inserting nothing: handled via end mark for + // empty paragraphs; mid-paragraph carets keep surrounding formatting. + } + return ok(); + } + let ids = st.selection.shapes.clone(); + if ids.is_empty() { + return Err(bad("format", "select text or a shape")); + } + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + match &mut sh.kind { + ShapeKind::Table(t) => { + for row in &mut t.rows { + for c in &mut row.cells { + ed::format_all(&mut c.text, f); + } + } + } + _ => { + let body = sh.text.get_or_insert_with(TextBody::default); + ed::format_all(body, f); + } + } + Ok(()) + })?; + for id in ids { + fit_text_box(s, id)?; + } + ok() +} + +/// Apply paragraph changes to the paragraphs touched by the selection (or all paragraphs of the +/// selected shapes). +pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut slidecraft_model::text::Paragraph) + Send + Sync)) -> Result { + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let (a, b) = t.ordered(); + edit_text(s, |body, _| { + if body.paragraphs.is_empty() { + body.paragraphs.push(Default::default()); + } + for pi in a.0..=b.0 { + if let Some(p) = body.paragraphs.get_mut(pi) { + f(p); + } + } + })?; + return ok(); + } + let ids = st.selection.shapes.clone(); + if ids.is_empty() { + return Err(bad("format", "select text or a shape")); + } + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + match &mut sh.kind { + ShapeKind::Table(t) => { + for row in &mut t.rows { + for c in &mut row.cells { + c.text.paragraphs.iter_mut().for_each(f); + } + } + } + _ => { + let body = sh.text.get_or_insert_with(TextBody::default); + if body.paragraphs.is_empty() { + body.paragraphs.push(Default::default()); + } + body.paragraphs.iter_mut().for_each(f); + } + } + Ok(()) + })?; + for id in ids { + fit_text_box(s, id)?; + } + ok() +} + +/// Apply body (text box) changes to the edited or selected shapes. +pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut slidecraft_model::text::BodyProps) + Send + Sync)) -> Result { + let st = s.doc()?; + let ids: Vec = match &st.selection.text { + Some(t) if !t.notes => vec![t.shape], + _ => st.selection.shapes.clone(), + }; + if ids.is_empty() { + return Err(bad("format", "select a shape")); + } + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + f(&mut sh.text.get_or_insert_with(TextBody::default).body); + Ok(()) + })?; + for id in ids { + fit_text_box(s, id)?; + } + ok() +} diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs new file mode 100644 index 0000000..b7ea2e5 --- /dev/null +++ b/crates/engine/src/cmd/transition.rs @@ -0,0 +1,124 @@ +//! Transitions tab. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use slidecraft_model::Transition; +use slidecraft_model::anim::TRANSITIONS; + +use super::*; +use crate::{Result, Session}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "transition.set", + "Transition", + ["Transitions", "Transition to This Slide"], + None, + "{kind: none|morph|fade|push|wipe|split|reveal|cut|randomBar|shape|uncover|cover|flash|… (transition.list), option?, duration?: ms, index?}", + has_slide, + set + ), + cmd!("transition.options", "Effect Options", ["Transitions", "Transition to This Slide"], None, "{option, index?}", has_slide, options), + cmd!( + "transition.timing", + "Timing", + ["Transitions", "Timing"], + None, + "{duration?: ms, onClick?: bool, after?: ms | null, index?}", + has_slide, + timing + ), + cmd!("transition.applyAll", "Apply To All", ["Transitions", "Timing"], None, "{}", has_slide, apply_all), + cmd!(query "transition.list", "Transition Gallery", [], None, "{} → [{id, label, category, duration, options}]", always, list), + ] +} + +fn slide_indices(s: &Session, p: &Value) -> Result> { + let st = s.doc()?; + if let Some(i) = usize_param(p, "index") { + return Ok(vec![i]); + } + let v: Vec = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); + Ok(if v.is_empty() { vec![st.selection.slide] } else { v }) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let kind = str_param(p, "kind").ok_or_else(|| bad("transition.set", "missing `kind`"))?; + let info = + TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?; + let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default(); + let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32); + let list = slide_indices(s, p)?; + s.edit(|doc, _| { + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + let sl = Arc::make_mut(sl); + let mut t = sl.transition.clone().unwrap_or_default(); + t.kind = info.0.to_string(); + t.option = option.clone(); + t.duration_ms = duration.unwrap_or(info.3); + t.raw = None; + sl.transition = if info.0 == "none" && t.advance_after_ms.is_none() && t.advance_on_click { None } else { Some(t) }; + } + } + Ok(()) + })?; + Ok(json!({"kind": info.0})) +} + +fn options(s: &mut Session, p: &Value) -> Result { + let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string(); + let list = slide_indices(s, p)?; + s.edit(|doc, _| { + for i in list { + if let Some(sl) = doc.slides.get_mut(i) + && let Some(t) = Arc::make_mut(sl).transition.as_mut() + { + t.option = option.clone(); + } + } + Ok(()) + })?; + ok() +} + +fn timing(s: &mut Session, p: &Value) -> Result { + let list = slide_indices(s, p)?; + s.edit(|doc, _| { + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + let t = Arc::make_mut(sl).transition.get_or_insert_with(Transition::default); + if let Some(d) = p.get("duration").and_then(Value::as_u64) { + t.duration_ms = d.min(59_000) as u32; + } + if let Some(c) = bool_param(p, "onClick") { + t.advance_on_click = c; + } + match p.get("after") { + Some(Value::Null) => t.advance_after_ms = None, + Some(v) => t.advance_after_ms = v.as_u64().map(|x| x.min(86_400_000) as u32), + None => {} + } + } + } + Ok(()) + })?; + ok() +} + +fn apply_all(s: &mut Session, _p: &Value) -> Result { + s.edit(|doc, sel| { + let t = doc.slides.get(sel.slide).and_then(|x| x.transition.clone()); + for sl in &mut doc.slides { + Arc::make_mut(sl).transition.clone_from(&t); + } + Ok(()) + })?; + ok() +} + +fn list(_s: &mut Session, _p: &Value) -> Result { + Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect())) +} diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs new file mode 100644 index 0000000..62a6b9f --- /dev/null +++ b/crates/engine/src/guard.rs @@ -0,0 +1,84 @@ +//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command — +//! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an +//! error, and the document is kept as it was before the command (and saved for crash recovery +//! when a recovery directory is set). It is a safety net: commands still must not panic. + +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use serde_json::Value; + +use crate::{EngineError, Result, Session}; + +/// Text of a panic payload (`panic!` with a literal or a formatted message). +pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".into()) +} + +/// Log every panic (message and location) before it unwinds, then run the previous hook. +/// Installed once per process, when the first [`Session`] is made. +pub fn install_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default(); + log::error!("panic{at}: {}", panic_message(info.payload())); + previous(info); + })); + }); +} + +impl Session { + /// Run command `id` (`f`) inside the guard. + pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision)); + match catch_unwind(AssertUnwindSafe(|| f(self))) { + Ok(r) => r, + Err(payload) => { + let msg = panic_message(payload.as_ref()); + // Keep the document as it was before the command. + if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut()) + && st.uid == uid + { + st.doc = doc; + st.selection = selection; + st.interaction = None; + st.revision = revision.max(st.revision).saturating_add(1); + } + log::error!("command `{id}` panicked: {msg}"); + Err(EngineError::Internal(id.to_string(), msg)) + } + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_panicking_command_is_an_error_and_keeps_the_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [36, 36, 200, 200]})).unwrap(); + let before = s.active().unwrap().doc.clone(); + // A command that edits the document and then panics half way. + let err = s + .guarded("test.panic", |s| { + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]}))?; + panic!("boom"); + }) + .unwrap_err(); + assert!(err.to_string().contains("test.panic") && err.to_string().contains("boom"), "{err}"); + let st = s.active().unwrap(); + assert!(std::sync::Arc::ptr_eq(&st.doc, &before), "the document is the one from before the command"); + // The session keeps working. + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap(); + } +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs new file mode 100644 index 0000000..a401e00 --- /dev/null +++ b/crates/engine/src/keys.rs @@ -0,0 +1,222 @@ +//! Keyboard: text-editing keys while editing text, nudges and shortcuts otherwise. Shared by the +//! UI, the control channel and MCP so a key does the same thing everywhere. + +use serde_json::{Value, json}; + +use crate::tools::Mods; +use crate::{Result, Session}; + +/// Does a shortcut like `Cmd+Shift+]` match `key` + `mods`? +pub fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool { + let parts: Vec<&str> = shortcut.split('+').collect(); + let (k, ms) = match parts.as_slice() { + [.., "", ""] => ("+", &parts[..parts.len().saturating_sub(2)]), + _ => match parts.split_last() { + Some((k, ms)) => (*k, ms), + None => return false, + }, + }; + let has = |m: &str| ms.iter().any(|x| x.eq_ignore_ascii_case(m)); + k.eq_ignore_ascii_case(key) && (has("cmd") || has("ctrl")) == mods.cmd && has("shift") == mods.shift && (has("alt") || has("option")) == mods.alt +} + +impl Session { + /// Handle one key press. `key` is a name (`Enter`, `Escape`, `Backspace`, `Delete`, `Tab`, + /// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`). + /// Returns `{handled: bool, …}`. + pub fn key(&mut self, key: &str, mods: Mods) -> Result { + let editing = self.active().is_some_and(|d| d.selection.text.is_some()); + let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); + let k = key.to_ascii_lowercase(); + let k = k.trim_start_matches("arrow"); + if editing && !mods.cmd { + let r = match k { + "left" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordLeft" } else { "left" }, "extend": mods.shift}))), + "right" => Some(self.execute("text.move", &json!({"to": if mods.alt { "wordRight" } else { "right" }, "extend": mods.shift}))), + "up" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraStart" } else { "up" }, "extend": mods.shift}))), + "down" => Some(self.execute("text.move", &json!({"to": if mods.alt { "paraEnd" } else { "down" }, "extend": mods.shift}))), + "home" => Some(self.execute("text.move", &json!({"to": "lineStart", "extend": mods.shift}))), + "end" => Some(self.execute("text.move", &json!({"to": "lineEnd", "extend": mods.shift}))), + "backspace" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordBackward" } else { "backward" }}))), + "delete" => Some(self.execute("text.delete", &json!({"dir": if mods.alt { "wordForward" } else { "forward" }}))), + "enter" | "return" => Some(self.execute("text.insert", &json!({"text": if mods.shift { "\u{b}" } else { "\n" }}))), + "escape" | "esc" => Some(self.execute("text.exit", &json!({}))), + "tab" => { + // Tab at the start of a paragraph changes the list level; elsewhere it types a tab. + let at_start = self.active().and_then(|d| d.selection.text.as_ref()).is_some_and(|t| t.ordered().0.1 == 0 || t.is_range()); + let in_cell = self.active().and_then(|d| d.selection.text.as_ref()).is_some_and(|t| t.cell.is_some()); + if in_cell { + Some(self.next_cell(mods.shift)) + } else if at_start { + Some(self.execute(if mods.shift { "format.outdent" } else { "format.indent" }, &json!({}))) + } else { + Some(self.execute("text.insert", &json!({"text": "\t"}))) + } + } + _ => None, + }; + if let Some(r) = r { + r?; + return Ok(json!({"handled": true})); + } + } + if !editing && has_shapes && !mods.cmd { + let step = if mods.alt { 1.0 } else { 6.0 }; + let d = match k { + "left" => Some((-step, 0.0)), + "right" => Some((step, 0.0)), + "up" => Some((0.0, -step)), + "down" => Some((0.0, step)), + _ => None, + }; + if let Some((dx, dy)) = d { + self.execute("shape.move", &json!({"dx": dx, "dy": dy}))?; + return Ok(json!({"handled": true})); + } + match k { + "backspace" | "delete" => { + self.execute("edit.delete", &json!({}))?; + return Ok(json!({"handled": true})); + } + "enter" | "return" | "f2" => { + self.execute("text.edit", &json!({}))?; + return Ok(json!({"handled": true})); + } + "escape" | "esc" => { + self.execute("edit.deselect", &json!({}))?; + return Ok(json!({"handled": true})); + } + "tab" => { + self.cycle_selection(!mods.shift)?; + return Ok(json!({"handled": true})); + } + _ => {} + } + } + if !editing && !has_shapes && !mods.cmd { + let r = match k { + "pagedown" | "down" | "right" => Some("slide.next"), + "pageup" | "up" | "left" => Some("slide.previous"), + "home" => Some("slide.first"), + "end" => Some("slide.last"), + "enter" | "return" => Some("slide.new"), + "tab" => { + self.cycle_selection(!mods.shift)?; + return Ok(json!({"handled": true})); + } + _ => None, + }; + if let Some(id) = r { + self.execute(id, &json!({}))?; + return Ok(json!({"handled": true})); + } + } + // Shortcuts from the registry. + let key_name = match k { + "enter" | "return" => "Enter", + "escape" | "esc" => "Escape", + "delete" => "Delete", + "backspace" => "Backspace", + "pageup" => "PageUp", + "pagedown" => "PageDown", + "home" => "Home", + "end" => "End", + "tab" => "Tab", + "space" | " " => "Space", + _ => key, + }; + let found = crate::command_specs().iter().find(|c| c.shortcut.is_some_and(|s| shortcut_matches(s, key_name, mods))).map(|c| c.id); + if let Some(id) = found { + let r = self.execute(id, &json!({}))?; + return Ok(json!({"handled": true, "command": id, "result": r})); + } + Ok(json!({"handled": false})) + } + + /// Type text: into the text being edited, or (a shape selected) start editing it. + pub fn type_text(&mut self, text: &str) -> Result { + let st = self.doc()?; + if st.selection.text.is_none() { + let Some(id) = st.selection.shapes.first().copied() else { return Ok(json!({"handled": false})) }; + if st.selection.shapes.len() != 1 { + return Ok(json!({"handled": false})); + } + // Typing on a selected shape replaces its text (PowerPoint behaviour). + self.execute("text.edit", &json!({"id": id}))?; + self.execute("text.selectAll", &json!({}))?; + } + self.execute("text.insert", &json!({"text": text})) + } + + fn cycle_selection(&mut self, forward: bool) -> Result<()> { + let st = self.doc()?; + let ids: Vec<_> = st.shapes().iter().filter(|s| !s.hidden).map(|s| s.id).collect(); + if ids.is_empty() { + return Ok(()); + } + let cur = st.selection.shapes.first().and_then(|c| ids.iter().position(|x| x == c)); + let n = ids.len(); + let next = match cur { + Some(i) if forward => (i + 1) % n, + Some(i) => (i + n - 1) % n, + None if forward => 0, + None => n - 1, + }; + let id = ids.get(next).copied(); + self.select(|_, sel| { + sel.shapes = id.into_iter().collect(); + sel.text = None; + }) + } + + fn next_cell(&mut self, back: bool) -> Result { + let st = self.doc()?; + let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) }; + let Some((r, c)) = t.cell else { return Ok(Value::Null) }; + let Some(slidecraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) }; + let (nr, nc) = (tb.n_rows(), tb.n_cols()); + let idx = r * nc + c; + if !back && idx + 1 >= nr * nc { + // Tab in the last cell adds a row. + self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?; + return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]})); + } + let ni = if back { idx.saturating_sub(1) } else { idx + 1 }; + self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?; + self.execute("text.selectAll", &json!({})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shortcut_parsing() { + let m = |shift, alt, cmd| Mods { shift, alt, cmd }; + assert!(shortcut_matches("Cmd+B", "b", m(false, false, true))); + assert!(!shortcut_matches("Cmd+B", "b", m(true, false, true))); + assert!(shortcut_matches("Cmd+Shift+]", "]", m(true, false, true))); + assert!(shortcut_matches("Cmd+Shift+.", ".", m(true, false, true))); + assert!(shortcut_matches("Delete", "Delete", m(false, false, false))); + } + + #[test] + fn keys_drive_editing() { + let mut s = Session::with_new(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("edit.select", &json!({"ids": [title]})).unwrap(); + s.type_text("Hi").unwrap(); + s.key("Enter", Mods::default()).unwrap(); + s.type_text("there").unwrap(); + s.key("Backspace", Mods::default()).unwrap(); + s.key("Escape", Mods::default()).unwrap(); + let t = s.execute("text.get", &json!({"id": title})).unwrap(); + assert_eq!(t["text"], "Hi\nther"); + let r = s.key("b", Mods { cmd: true, ..Default::default() }).unwrap(); + assert_eq!(r["command"], "format.bold"); + s.key("Right", Mods::default()).unwrap(); + s.key("Delete", Mods::default()).unwrap(); + assert_eq!(s.doc().unwrap().current_slide().unwrap().shapes.len(), 1); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs new file mode 100644 index 0000000..f9b88fa --- /dev/null +++ b/crates/engine/src/lib.rs @@ -0,0 +1,437 @@ +//! The SlideCraft engine façade. +//! +//! Every user-visible action is a command with a stable id (`slide.new`, `shape.insert`, +//! `text.insert`, `format.bold`…) and JSON parameters. The egui UI, the CLI, the control channel +//! and MCP all go through [`Session::execute`]. Pointer tools reduce to commands too, so every +//! gesture is journaled, undoable and replayable. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod cmd; +pub mod guard; +pub mod keys; +pub mod links; +pub mod sample; +pub mod tools; + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use slidecraft_model::{Presentation, Shape, ShapeId, Slide, SlideId}; + +pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +pub use slidecraft_geom as geom; +pub use slidecraft_model as model; +pub use slidecraft_render as render; +pub use tools::{Mods, PointerEvent, PointerKind, ToolKind}; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("command `{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("no active presentation")] + NoDocument, + #[error("{0}")] + Other(String), + #[error("internal error in `{0}` (the presentation was kept as it was): {1}")] + Internal(String, String), +} + +impl From for EngineError { + fn from(e: slidecraft_model::ModelError) -> Self { + EngineError::Other(e.to_string()) + } +} + +pub type Result = std::result::Result; + +/// Text being edited in place: a shape's body (or a table cell, or the slide's notes). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextSel { + pub shape: ShapeId, + /// Selection anchor and caret: (paragraph, char). + pub anchor: (usize, usize), + pub caret: (usize, usize), + /// Editing a table cell (row, col). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cell: Option<(usize, usize)>, + /// Editing the slide's speaker notes (shape is ignored). + #[serde(default)] + pub notes: bool, +} + +impl TextSel { + pub fn is_range(&self) -> bool { + self.anchor != self.caret + } + pub fn ordered(&self) -> ((usize, usize), (usize, usize)) { + if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) } + } +} + +/// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Target { + #[default] + Slides, + Master { + master: usize, + }, + Layout { + master: usize, + layout: usize, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Selection { + /// Current slide index. + pub slide: usize, + /// Slides selected in the thumbnail pane / sorter (includes the current one when non-empty). + pub slides: Vec, + /// Selected shapes on the current slide (top-level or inside groups). + pub shapes: Vec, + pub text: Option, + /// Selected table cells: (table, (r0, c0), (r1, c1)). + pub cells: Option<(ShapeId, (usize, usize), (usize, usize))>, + pub target: Target, +} + +#[derive(Clone, Debug)] +pub struct HistoryEntry { + pub label: String, + pub doc: Arc, + pub selection: Selection, +} + +#[derive(Clone, Debug, Default)] +pub struct History { + pub undo: Vec, + pub redo: Vec, + pub limit: usize, +} + +/// A live pointer interaction: the document before it began (one undo step at the end). +#[derive(Clone, Debug)] +pub struct Interaction { + pub label: String, + pub doc: Arc, + pub selection: Selection, +} + +#[derive(Clone, Debug)] +pub struct DocState { + pub doc: Arc, + pub selection: Selection, + pub history: History, + pub path: Option, + pub revision: u64, + pub saved_doc: Arc, + pub interaction: Option, + pub uid: u64, + pub title: String, +} + +static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +impl DocState { + pub fn new(doc: Presentation, path: Option, title: String) -> Self { + let doc = Arc::new(doc); + DocState { + saved_doc: doc.clone(), + doc, + selection: Selection::default(), + history: History { limit: 500, ..Default::default() }, + path, + revision: 1, + interaction: None, + uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + title, + } + } + pub fn is_dirty(&self) -> bool { + !Arc::ptr_eq(&self.doc, &self.saved_doc) + } + pub fn title(&self) -> String { + self.path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_stem()) + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| self.title.clone()) + } + pub fn current_slide(&self) -> Option<&Slide> { + self.doc.slides.get(self.selection.slide).map(|s| s.as_ref()) + } + /// Shapes of the edited tree (current slide, or the master/layout in Slide Master view). + pub fn shapes(&self) -> &[Shape] { + match self.selection.target { + Target::Slides => self.current_slide().map(|s| s.shapes.as_slice()).unwrap_or(&[]), + Target::Master { master } => self.doc.masters.get(master).map(|m| m.shapes.as_slice()).unwrap_or(&[]), + Target::Layout { master, layout } => { + self.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| l.shapes.as_slice()).unwrap_or(&[]) + } + } + } + pub fn shape(&self, id: ShapeId) -> Option<&Shape> { + slidecraft_model::find_shape(self.shapes(), id) + } + pub fn selected_shapes(&self) -> Vec<&Shape> { + self.selection.shapes.iter().filter_map(|id| self.shape(*id)).collect() + } +} + +/// Mutable access to the edited shape tree of a presentation. +pub fn shapes_mut<'a>(doc: &'a mut Presentation, sel: &Selection) -> Option<&'a mut Vec> { + match sel.target { + Target::Slides => doc.slides.get_mut(sel.slide).map(|s| &mut Arc::make_mut(s).shapes), + Target::Master { master } => doc.masters.get_mut(master).map(|m| &mut Arc::make_mut(m).shapes), + Target::Layout { master, layout } => { + doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)).map(|l| &mut l.shapes) + } + } +} + +/// Engine preferences (the UI keeps its own). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + pub snap_to_grid: bool, + pub grid_spacing: f64, + pub smart_guides: bool, + /// Ctrl+D / Paste offset. + pub duplicate_offset: f64, + pub autocorrect: bool, + pub smart_quotes: bool, + pub author: String, + pub undo_limit: usize, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + snap_to_grid: true, + grid_spacing: 6.0, + smart_guides: true, + duplicate_offset: 12.0, + autocorrect: true, + smart_quotes: true, + author: "Presenter".into(), + undo_limit: 500, + } + } +} + +/// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum UiRequest { + Dialog { id: String, params: Value }, + Pane { id: String }, + StartShow { from: usize }, + Message { text: String }, + PickFile { purpose: String }, +} + +/// Clipboard contents copied within the app. +#[derive(Clone, Debug, Default)] +pub struct Clipboard { + pub shapes: Vec, + pub slides: Vec, + /// Media the copied shapes/slides reference. + pub media: Vec, + pub text: Option, + pub plain: String, + /// How many times the shapes were pasted (each paste offsets further). + pub pastes: u32, +} + +pub struct Session { + docs: Vec, + active: Option, + pub prefs: Prefs, + pub journal: Vec<(String, Value)>, + pub clipboard: Clipboard, + pub ui_requests: Vec, + pub tool: tools::ToolState, + pub(crate) untitled: u32, + /// Format Painter: copied shape/text formatting and whether it stays on (double-click). + pub painter: Option<(cmd::format::Painted, bool)>, + /// Set as Default Shape: the look new shapes get. + pub default_look: Option, +} + +impl Default for Session { + fn default() -> Self { + Self::new() + } +} + +impl Session { + pub fn new() -> Self { + guard::install_panic_hook(); + Session { + docs: vec![], + active: None, + prefs: Prefs::default(), + journal: vec![], + clipboard: Clipboard::default(), + ui_requests: vec![], + tool: tools::ToolState::default(), + untitled: 0, + default_look: None, + painter: None, + } + } + /// A session with one new presentation open. + pub fn with_new() -> Self { + let mut s = Self::new(); + let _ = s.execute("file.new", &Value::Null); + s + } + pub fn documents(&self) -> &[DocState] { + &self.docs + } + pub fn active_index(&self) -> Option { + self.active + } + pub fn active(&self) -> Option<&DocState> { + self.active.and_then(|i| self.docs.get(i)) + } + pub fn active_mut(&mut self) -> Option<&mut DocState> { + self.active.and_then(|i| self.docs.get_mut(i)) + } + pub fn doc(&self) -> Result<&DocState> { + self.active().ok_or(EngineError::NoDocument) + } + pub fn doc_mut(&mut self) -> Result<&mut DocState> { + self.active_mut().ok_or(EngineError::NoDocument) + } + pub fn set_active(&mut self, i: usize) { + if i < self.docs.len() { + self.active = Some(i); + } + } + pub fn add_document(&mut self, d: DocState) -> usize { + self.docs.push(d); + self.active = Some(self.docs.len() - 1); + self.docs.len() - 1 + } + pub fn close_document(&mut self, i: usize) { + if i < self.docs.len() { + self.docs.remove(i); + self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; + } + } + pub fn next_untitled(&mut self) -> String { + self.untitled += 1; + format!("Presentation{}", self.untitled) + } + + /// Run command `id`. A panic inside it becomes [`EngineError::Internal`] and leaves the + /// presentation as it was. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + self.guarded(id, |s| s.execute_unguarded(id, params)) + } + + fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + if let Err(e) = (spec.enabled)(self) { + let named = e == cmd::NOTHING_SELECTED && (params.get("ids").is_some() || params.get("id").is_some()); + if !named { + return Err(EngineError::Disabled(id.into(), e)); + } + } + let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone())); + let r = (spec.run)(self, params)?; + if let Some(st) = self.active_mut() { + clamp_selection(st); + } + if let (Some((uid, old, sel)), Some(st)) = (before, self.active_mut()) + && st.uid == uid + && !Arc::ptr_eq(&old, &st.doc) + && st.interaction.is_none() + && spec.undoable + { + push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel }); + } + if spec.journal { + self.journal.push((id.to_string(), params.clone())); + if self.journal.len() > 10_000 { + self.journal.drain(..1000); + } + } + Ok(r) + } + + pub fn commands(&self) -> Vec { + command_specs().iter().map(|c| c.info(self)).collect() + } + + /// Mutate the active presentation (copy-on-write) and bump the revision. + pub fn edit(&mut self, f: impl FnOnce(&mut Presentation, &mut Selection) -> Result) -> Result { + let st = self.doc_mut()?; + let mut doc = (*st.doc).clone(); + let mut sel = st.selection.clone(); + let r = f(&mut doc, &mut sel)?; + st.doc = Arc::new(doc); + st.selection = sel; + st.revision += 1; + Ok(r) + } + + /// Change only the selection (no undo step). + pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> { + let st = self.doc_mut()?; + let doc = st.doc.clone(); + f(&doc, &mut st.selection); + clamp_selection(st); + st.revision += 1; + Ok(()) + } +} + +/// Keep the selection pointing at things that exist. +fn clamp_selection(st: &mut DocState) { + let n = st.doc.slides.len(); + if n == 0 { + st.selection.slide = 0; + } else if st.selection.slide >= n { + st.selection.slide = n - 1; + } + let ids: Vec = st.selection.shapes.iter().copied().filter(|id| st.shape(*id).is_some()).collect(); + st.selection.shapes = ids; + if let Some(t) = &st.selection.text + && !t.notes + && st.shape(t.shape).is_none() + { + st.selection.text = None; + } + let slides: Vec = st.selection.slides.iter().copied().filter(|id| st.doc.slide(*id).is_some()).collect(); + st.selection.slides = slides; + match st.selection.target { + Target::Master { master } if master >= st.doc.masters.len() => st.selection.target = Target::Slides, + Target::Layout { master, layout } if st.doc.masters.get(master).is_none_or(|m| layout >= m.layouts.len()) => { + st.selection.target = Target::Slides + } + _ => {} + } +} + +pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) { + st.history.undo.push(e); + st.history.redo.clear(); + if st.history.undo.len() > st.history.limit.max(1) { + st.history.undo.remove(0); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs new file mode 100644 index 0000000..0dc6a15 --- /dev/null +++ b/crates/engine/src/links.rs @@ -0,0 +1,8 @@ +//! Community and project links (shown in Help, About, the CLI and MCP). + +pub const DISCORD: &str = "https://discord.gg/artcraft"; +pub const WEBSITE: &str = "https://getartcraft.com/"; +pub const APPS: &str = "https://getartcraft.com/apps"; +pub const APP_PAGE: &str = "https://getartcraft.com/apps/slidecraft"; +pub const GITHUB: &str = "https://github.com/storytold/slidecraft"; +pub const ISSUES: &str = "https://github.com/storytold/slidecraft/issues"; diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs new file mode 100644 index 0000000..886ffaa --- /dev/null +++ b/crates/engine/src/sample.rs @@ -0,0 +1,138 @@ +//! A sample deck built entirely in code (our own text, shapes and data), for demos, screenshots +//! and tests: `slidecraft --sample`. + +use serde_json::{Value, json}; + +use crate::{Result, Session}; + +fn run(s: &mut Session, id: &str, p: Value) -> Result { + s.execute(id, &p) +} + +fn last_id(v: &Value) -> u64 { + v.get("id").and_then(Value::as_u64).unwrap_or(0) +} + +/// Open the sample presentation in a new document. +pub fn open_sample(s: &mut Session) -> Result<()> { + run(s, "file.new", json!({"theme": "SlideCraft"}))?; + let st = s.doc()?; + let title = st.current_slide().and_then(|x| x.shapes.first()).map(|x| x.id.0).unwrap_or(0); + let sub = st.current_slide().and_then(|x| x.shapes.get(1)).map(|x| x.id.0).unwrap_or(0); + run(s, "text.set", json!({"id": title, "text": "Northern Lights"}))?; + run(s, "text.set", json!({"id": sub, "text": "A short tour of the aurora — where it comes from, when to look, and how to photograph it"}))?; + run(s, "slide.notes", json!({"text": "Welcome everyone. This deck was made in SlideCraft."}))?; + // Decorative bands on the title slide. + let b = run(s, "shape.insert", json!({"preset": "rect", "rect": [0, 470, 960, 70]}))?; + run(s, "shape.fill", json!({"id": last_id(&b), "gradient": {"stops": [[0, "accent1"], [0.5, "accent4"], [1, "accent2"]], "angle": 0}}))?; + run(s, "shape.line", json!({"id": last_id(&b), "none": true}))?; + run(s, "shape.rename", json!({"id": last_id(&b), "name": "Aurora Band"}))?; + run(s, "transition.set", json!({"kind": "fade"}))?; + + // Agenda. + run(s, "slide.new", json!({"layout": "titleAndContent", "title": "What we'll cover"}))?; + let body = s.doc()?.current_slide().and_then(|x| x.shapes.get(1)).map(|x| x.id.0).unwrap_or(0); + run( + s, + "text.set", + json!({"id": body, "text": "The science: solar wind meets the magnetosphere\nWhere and when to see it\n\tHigh latitudes, dark skies, long winter nights\n\tKp index and space-weather forecasts\nPhotographing the aurora\nSafety and etiquette in the field"}), + )?; + run(s, "transition.set", json!({"kind": "push", "option": "u"}))?; + run(s, "edit.select", json!({"ids": [body]}))?; + run(s, "animation.set", json!({"effect": "fade", "class": "entrance"}))?; + run(s, "animation.options", json!({"textBuild": "byParagraph"}))?; + + // Section header. + run(s, "slide.new", json!({"layout": "sectionHeader", "title": "The Science", "body": "Charged particles, magnetic fields and glowing gases"}))?; + run(s, "design.background", json!({"color": "dk2"}))?; + let st = s.doc()?; + let ids: Vec = st.current_slide().map(|x| x.shapes.iter().map(|s| s.id.0 as u64).collect()).unwrap_or_default(); + run(s, "edit.select", json!({"ids": ids}))?; + run(s, "format.color", json!({"color": "#FFFFFF"}))?; + run(s, "transition.set", json!({"kind": "wipe", "option": "r"}))?; + + // Process diagram from shapes. + run(s, "slide.new", json!({"layout": "titleOnly", "title": "From the Sun to your sky"}))?; + let steps = [("Solar wind", "accent2"), ("Magnetosphere", "accent1"), ("Particles collide", "accent4"), ("Light!", "accent3")]; + let mut prev: Option = None; + for (i, (label, color)) in steps.iter().enumerate() { + let x = 60.0 + i as f64 * 220.0; + let v = run(s, "shape.insert", json!({"preset": if i == 3 { "star5" } else { "roundRect" }, "rect": [x, 210, 190, 120], "text": label}))?; + let id = last_id(&v); + run(s, "shape.fill", json!({"id": id, "color": color}))?; + run(s, "shape.line", json!({"id": id, "none": true}))?; + run(s, "shape.effects", json!({"id": id, "shadow": "bottom"}))?; + run(s, "edit.select", json!({"ids": [id]}))?; + run(s, "format.size", json!({"size": 17}))?; + run(s, "format.bold", json!({"on": true}))?; + run(s, "animation.add", json!({"effect": "zoom", "class": "entrance", "start": if i == 0 { "onClick" } else { "afterPrevious" }}))?; + if let Some(p) = prev { + let _ = p; + let a = run(s, "shape.insert", json!({"preset": "rightArrow", "rect": [x - 36.0, 255, 32, 30]}))?; + run(s, "shape.fill", json!({"id": last_id(&a), "color": "tx2"}))?; + run(s, "shape.line", json!({"id": last_id(&a), "none": true}))?; + } + prev = Some(id); + } + let cap = + run(s, "insert.textBox", json!({"rect": [60, 380, 840, 60], "text": "Oxygen glows green and red; nitrogen adds blue and purple fringes."}))?; + run(s, "edit.select", json!({"ids": [last_id(&cap)]}))?; + run(s, "format.alignCenter", json!({}))?; + run(s, "format.size", json!({"size": 20}))?; + run(s, "format.italic", json!({"on": true}))?; + run(s, "transition.set", json!({"kind": "morph"}))?; + + // Chart. + run(s, "slide.new", json!({"layout": "titleOnly", "title": "Best months to look (clear nights)"}))?; + run( + s, + "insert.chart", + json!({"type": "column", "rect": [80, 140, 800, 360], "title": "Average clear, dark nights per month", "categories": ["Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"], "series": [{"name": "Tromsø", "values": [6, 8, 7, 6, 7, 9, 11]}, {"name": "Fairbanks", "values": [9, 10, 8, 9, 11, 12, 13]}, {"name": "Yellowknife", "values": [8, 11, 9, 10, 12, 13, 14]}]}), + )?; + run(s, "transition.set", json!({"kind": "fade"}))?; + + // Table. + run(s, "slide.new", json!({"layout": "titleOnly", "title": "Camera settings that work"}))?; + run( + s, + "insert.table", + json!({"rows": 5, "cols": 3, "rect": [100, 150, 760, 260], "data": [["Setting", "Faint aurora", "Bright, active aurora"], ["Aperture", "f/1.4 – f/2.8", "f/2.8"], ["Shutter", "8 – 15 s", "1 – 4 s"], ["ISO", "1600 – 3200", "800 – 1600"], ["Focus", "Manual, on a bright star", "Manual, on a bright star"]]}), + )?; + run(s, "transition.set", json!({"kind": "cover", "option": "l"}))?; + + // Two content: comparison with shapes. + run(s, "slide.new", json!({"layout": "twoContent", "title": "Field checklist"}))?; + let st = s.doc()?; + let (l, r) = + st.current_slide().map(|x| (x.shapes.get(1).map(|s| s.id.0).unwrap_or(0), x.shapes.get(2).map(|s| s.id.0).unwrap_or(0))).unwrap_or((0, 0)); + run(s, "text.set", json!({"id": l, "text": "Bring\n\tTripod and spare batteries\n\tHeadlamp with a red mode\n\tWarm layers and a thermos"}))?; + run( + s, + "text.set", + json!({"id": r, "text": "Remember\n\tCheck the forecast and cloud cover\n\tLet your eyes adapt for 20 minutes\n\tLeave no trace"}), + )?; + run(s, "transition.set", json!({"kind": "split"}))?; + + // Closing. + run(s, "slide.new", json!({"layout": "title", "title": "Clear skies!", "body": "Made with SlideCraft — free and open source"}))?; + let moon = run(s, "shape.insert", json!({"preset": "moon", "rect": [820, 40, 80, 120]}))?; + run(s, "shape.fill", json!({"id": last_id(&moon), "color": "accent6"}))?; + run(s, "shape.line", json!({"id": last_id(&moon), "none": true}))?; + run(s, "shape.effects", json!({"id": last_id(&moon), "glow": {"color": "accent6", "radius": 10}}))?; + run(s, "transition.set", json!({"kind": "fade"}))?; + + run(s, "section.add", json!({"name": "Introduction", "at": 0}))?; + run(s, "section.add", json!({"name": "Science", "at": 2}))?; + run(s, "section.add", json!({"name": "In the Field", "at": 5}))?; + run(s, "slide.go", json!({"index": 0}))?; + run(s, "edit.deselect", json!({}))?; + run(s, "edit.deselect", json!({}))?; + // The sample opens clean: no undo history, not dirty. + if let Some(st) = s.active_mut() { + st.history.undo.clear(); + st.history.redo.clear(); + st.saved_doc = st.doc.clone(); + st.title = "Northern Lights".into(); + } + Ok(()) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 0000000..b8b2008 --- /dev/null +++ b/crates/engine/src/tests.rs @@ -0,0 +1,250 @@ +use serde_json::json; + +use crate::*; + +fn session() -> Session { + Session::with_new() +} + +#[test] +fn new_presentation_and_slides() { + let mut s = session(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 1); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 3); + assert_eq!(s.doc().unwrap().selection.slide, 2); + s.execute("slide.duplicate", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 4); + s.execute("slide.move", &json!({"from": 3, "to": 0})).unwrap(); + s.execute("slide.delete", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 4); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 3); +} + +#[test] +fn insert_shape_and_format() { + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100], "text": "Hi"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.line", &json!({"width": 3, "color": "accent2", "dash": "dash"})).unwrap(); + s.execute("shape.rotate", &json!({"deg": 30})).unwrap(); + s.execute("shape.move", &json!({"dx": 10, "dy": -5})).unwrap(); + s.execute("format.bold", &json!({})).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert_eq!(v["box"]["x"], 110.0); + assert_eq!(v["box"]["rot"], 30.0); + let runs = &v["shape"]["text"]["paragraphs"][0]["runs"][0]["props"]; + assert_eq!(runs["bold"], true); + // Undo restores step by step. + for _ in 0..5 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert!(v["shape"]["fill"].is_null()); +} + +#[test] +fn text_editing_flow() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.insert", &json!({"text": "Hello world"})).unwrap(); + s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap(); + s.execute("format.italic", &json!({})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + s.execute("text.delete", &json!({"dir": "backward"})).unwrap(); + s.execute("text.insert", &json!({"text": "D\nSecond"})).unwrap(); + let t = s.execute("text.get", &json!({"id": title})).unwrap(); + assert_eq!(t["text"], "Hello worlD\nSecond"); + s.execute("text.exit", &json!({})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_none()); + assert_eq!(s.doc().unwrap().selection.shapes.len(), 1); +} + +#[test] +fn smart_quotes_and_autocorrect() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + for c in ["\"", "h", "i", "\"", " ", "t", "e", "h", " "] { + s.execute("text.insert", &json!({"text": c})).unwrap(); + } + let t = s.execute("text.get", &json!({"id": title})).unwrap(); + assert_eq!(t["text"], "“hi” the "); +} + +#[test] +fn clipboard_copy_paste_shapes_and_slides() { + let mut s = session(); + s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10, 10, 50, 50]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let n = s.doc().unwrap().current_slide().unwrap().shapes.len(); + assert_eq!(n, 2 + 3); + s.execute("edit.copy", &json!({"scope": "slides"})).unwrap(); + s.execute("edit.paste", &json!({"scope": "slides"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), 2); + assert!(s.doc().unwrap().doc.validate().is_empty()); +} + +#[test] +fn group_ungroup_roundtrip_positions() { + let mut s = session(); + let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [300, 200, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [a, b]})).unwrap(); + let g = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("shape.move", &json!({"id": g, "dx": 10})).unwrap(); + s.execute("arrange.ungroup", &json!({"ids": [g]})).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": b})).unwrap(); + assert!((v["box"]["x"].as_f64().unwrap() - 310.0).abs() < 1e-6, "{v}"); +} + +#[test] +fn align_and_distribute() { + let mut s = session(); + let mut ids = vec![]; + for (x, y) in [(10.0, 10.0), (200.0, 50.0), (500.0, 90.0)] { + ids.push(s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 40, 40]})).unwrap()["id"].as_u64().unwrap()); + } + s.execute("edit.select", &json!({"ids": ids})).unwrap(); + s.execute("arrange.align", &json!({"edge": "top"})).unwrap(); + s.execute("arrange.distribute", &json!({"dir": "horizontal"})).unwrap(); + let ys: Vec = ids.iter().map(|i| s.execute("shape.inspect", &json!({"id": i})).unwrap()["box"]["y"].as_f64().unwrap()).collect(); + assert!(ys.iter().all(|y| (*y - 10.0).abs() < 1e-6)); + let x1 = s.execute("shape.inspect", &json!({"id": ids[1]})).unwrap()["box"]["x"].as_f64().unwrap(); + assert!((x1 - 255.0).abs() < 1e-6, "{x1}"); +} + +#[test] +fn pointer_draw_move_resize() { + let mut s = session(); + s.set_tool(ToolKind::Shape { preset: "rect".into() }); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + s.pointer(ev(PointerKind::Down, 100.0, 100.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 200.0, 180.0)).unwrap(); + let r = s.pointer(ev(PointerKind::Up, 200.0, 180.0)).unwrap(); + let id = r["id"].as_u64().unwrap(); + assert_eq!(s.tool.kind, ToolKind::Select); + // Move by dragging the middle. + s.prefs.snap_to_grid = false; + s.prefs.smart_guides = false; + s.pointer(ev(PointerKind::Down, 150.0, 140.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 170.0, 150.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 170.0, 150.0)).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert_eq!(v["box"]["x"], 120.0); + assert_eq!(v["box"]["y"], 110.0); + // One undo step per gesture. + s.execute("edit.undo", &json!({})).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert_eq!(v["box"]["x"], 100.0); + // Resize from the SE handle. + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.pointer(ev(PointerKind::Down, 200.0, 180.0)).unwrap(); + s.pointer(ev(PointerKind::Drag, 250.0, 200.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 250.0, 200.0)).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert_eq!(v["box"]["w"], 150.0); +} + +#[test] +fn click_into_placeholder_starts_editing() { + let mut s = session(); + let ev = |kind, x, y| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0 }; + s.pointer(ev(PointerKind::Down, 480.0, 200.0)).unwrap(); + s.pointer(ev(PointerKind::Up, 480.0, 200.0)).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some()); + s.execute("text.insert", &json!({"text": "Typed"})).unwrap(); + assert_eq!(s.doc().unwrap().current_slide().unwrap().title(), "Typed"); +} + +#[test] +fn sample_deck_builds_and_renders() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.slides.len() >= 8); + assert!(st.doc.validate().is_empty()); + assert!(!st.is_dirty()); + for i in 0..st.doc.slides.len() { + let img = slidecraft_render::render_slide(&st.doc, i, &slidecraft_render::RenderOpts { scale: 0.2, ..Default::default() }); + assert_eq!(img.width, 192); + } + let v = s.execute("document.inspect", &json!({})).unwrap(); + assert!(v["slides"].as_array().unwrap().len() >= 8); +} + +#[test] +fn save_and_reopen_native() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + let bytes = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + let n = s.doc().unwrap().doc.slides.len(); + s.execute("file.openBytes", &json!({"name": "x.slidecraft", "data": bytes})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides.len(), n); + assert_eq!(s.documents().len(), 2); +} + +#[test] +fn every_command_survives_empty_and_hostile_params() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + s.execute("edit.select", &json!({"ids": [s.doc().unwrap().current_slide().unwrap().shapes[0].id.0]})).unwrap(); + let ids: Vec<&str> = command_specs() + .iter() + .map(|c| c.id) + .filter(|id| !matches!(*id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.export" | "file.saveTemplate")) + .collect(); + for p in [ + json!({}), + json!(null), + json!([1, 2]), + json!({"index": 99999, "ids": [999999], "id": -1, "text": "x", "rect": "bad", "color": 5, "size": 1e308}), + ] { + for id in &ids { + let _ = s.execute(id, &p); + } + } + assert!(s.active().is_some()); + for d in s.documents() { + assert!(d.doc.validate().is_empty(), "{:?}", d.doc.validate()); + } +} + +#[test] +fn base64_roundtrip() { + for data in [vec![], vec![0u8], vec![1, 2], vec![1, 2, 3], (0..=255u8).collect::>()] { + assert_eq!(cmd::base64_decode(&cmd::base64_encode(&data)).unwrap(), data); + } + assert!(cmd::base64_decode("!!").is_none()); +} + +#[test] +fn layout_change_keeps_text() { + let mut s = session(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + s.execute("slide.layout", &json!({"layout": "twoContent"})).unwrap(); + let v = s.execute("slide.inspect", &json!({})).unwrap(); + let texts: Vec = v["shapes"].as_array().unwrap().iter().filter_map(|x| x["text"].as_str().map(String::from)).collect(); + assert!(texts.contains(&"T".to_string()) && texts.contains(&"B".to_string()), "{v}"); + assert_eq!(v["layout"], "Two Content"); +} + +#[test] +fn themes_and_slide_size() { + let mut s = session(); + s.execute("design.theme", &json!({"name": "Ember"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.name, "Ember"); + s.execute("design.slideSize", &json!({"preset": "standard"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slide_size.width, 720.0); + s.execute("design.headerFooter", &json!({"slideNumber": true, "footer": true, "footerText": "Hello"})).unwrap(); + let v = s.execute("slide.inspect", &json!({})).unwrap(); + assert!(v["shapes"].as_array().unwrap().iter().any(|x| x["placeholder"] == "sldNum")); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs new file mode 100644 index 0000000..7fc7754 --- /dev/null +++ b/crates/engine/src/tools.rs @@ -0,0 +1,1103 @@ +//! Pointer tools. Gestures arrive in slide coordinates (points) through [`Session::pointer`] from +//! the UI, the control channel or MCP alike; each finished gesture is one undo step. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use slidecraft_geom::{Affine, Point, Rect, Vec2, Xfrm}; +use slidecraft_model::{Shape, ShapeId, ShapeKind}; + +use crate::cmd::{self, xfrm_of}; +use crate::{HistoryEntry, Result, Session, TextSel}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PointerKind { + #[default] + Down, + Drag, + Up, + Move, + DoubleClick, + TripleClick, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Mods { + pub shift: bool, + pub alt: bool, + pub cmd: bool, +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PointerEvent { + pub kind: PointerKind, + pub x: f64, + pub y: f64, + pub mods: Mods, + /// Hit tolerance in points (≈ 4 screen px at the current zoom). + pub tol: f64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ToolKind { + #[default] + Select, + /// Draw a preset shape. + Shape { + preset: String, + }, + TextBox, + /// Ink: pen (`pen`, `highlighter`) or `eraser`. + Ink { + mode: String, + color: slidecraft_color::Rgba, + width: f64, + }, +} + +/// What a point hits. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Hit { + /// Resize handle 0..8: NW, N, NE, E, SE, S, SW, W. + Resize { + id: ShapeId, + handle: u8, + }, + Rotate { + id: ShapeId, + }, + Adjust { + id: ShapeId, + index: usize, + }, + /// Line end 0/1 of a line-like shape. + LineEnd { + id: ShapeId, + end: u8, + }, + Shape { + id: ShapeId, + text: bool, + }, +} + +#[derive(Clone, Debug)] +enum Drag { + Move { ids: Vec, start: Point, origs: Vec<(ShapeId, Xfrm)>, moved: bool, duplicate: bool, enter_text: Option<(ShapeId, Point)> }, + Resize { id: ShapeId, handle: u8, orig: Xfrm, start: Point }, + LineEnd { id: ShapeId, end: u8, orig: Xfrm }, + Rotate { ids: Vec, center: Point, start: f64, origs: Vec<(ShapeId, Xfrm)> }, + Adjust { id: ShapeId, index: usize, orig: Xfrm }, + Marquee { start: Point, add: bool }, + Create { start: Point }, + TextSelect { shape: ShapeId }, + Ink { points: Vec<(f64, f64, f32)> }, +} + +/// A snapping guide line to draw (slide coordinates): vertical (x) or horizontal (y). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Guide { + pub vertical: bool, + pub pos: f64, + pub from: f64, + pub to: f64, +} + +#[derive(Clone, Debug, Default)] +pub struct ToolState { + pub kind: ToolKind, + /// Keep the tool after one use (double-click on a shape in the gallery: lock drawing mode). + pub sticky: bool, + drag: Option, + pub marquee: Option, + pub preview: Option, + pub guides: Vec, + pub hover: Option, + pub ink_preview: Vec<(f64, f64, f32)>, +} + +impl ToolState { + pub fn dragging(&self) -> bool { + self.drag.is_some() + } +} + +/// Handle positions in slide coordinates for a box (8 resize + rotate). +pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) { + let a = x.affine(); + let (w, h) = (x.w, x.h); + let local = [(0.0, 0.0), (w / 2.0, 0.0), (w, 0.0), (w, h / 2.0), (w, h), (w / 2.0, h), (0.0, h), (0.0, h / 2.0)]; + let pts = local.map(|(px, py)| a * Point::new(px, py)); + // The rotation handle sits above the top edge (in the shape's rotated frame). + let up = if x.flip_v { 1.0 } else { -1.0 }; + let rot = + Affine::translate(Vec2::new(x.x + w / 2.0, x.y + h / 2.0)) * Affine::rotate(x.rot.to_radians()) * Point::new(0.0, up * (h / 2.0 + 20.0)); + (pts, rot) +} + +impl Session { + pub fn set_tool(&mut self, kind: ToolKind) { + self.tool.kind = kind; + self.tool.drag = None; + self.tool.preview = None; + self.tool.marquee = None; + } + + /// What's under `p` on the current slide. + pub fn hit_test(&self, p: Point, tol: f64) -> Option { + let st = self.active()?; + // Handles of selected shapes first. + if st.selection.text.is_none() || st.selection.shapes.len() == 1 { + for id in st.selection.shapes.iter().rev() { + let Some(sh) = st.shape(*id) else { continue }; + let x = xfrm_of(&st.doc, &st.selection, sh); + if sh.is_line() { + let a = x.affine(); + for (end, q) in [(0u8, a * Point::new(0.0, 0.0)), (1u8, a * Point::new(x.w, x.h))] { + if (q - p).hypot() <= tol * 1.6 { + return Some(Hit::LineEnd { id: *id, end }); + } + } + continue; + } + let geo = slidecraft_render::shape_geometry(sh, x.w, x.h); + for hd in &geo.handles { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Adjust { id: *id, index: hd.index }); + } + } + let (pts, rot) = box_handles(&x); + if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { + return Some(Hit::Rotate { id: *id }); + } + for (i, q) in pts.iter().enumerate() { + if (*q - p).hypot() <= tol * 1.6 { + return Some(Hit::Resize { id: *id, handle: i as u8 }); + } + } + } + } + // Shapes, topmost first. + for sh in st.shapes().iter().rev() { + if sh.hidden || sh.locked { + continue; + } + if let Some(h) = hit_shape(st, sh, p, tol) { + return Some(h); + } + } + None + } + + /// Feed a pointer event to the active tool. Returns what happened (for agents). + pub fn pointer(&mut self, ev: PointerEvent) -> Result { + let tol = if ev.tol > 0.0 { ev.tol } else { 4.0 }; + let p = Point::new(ev.x, ev.y); + if !(p.x.is_finite() && p.y.is_finite()) { + return Ok(Value::Null); + } + match ev.kind { + PointerKind::Move => { + self.tool.hover = self.hit_test(p, tol); + Ok(Value::Null) + } + PointerKind::Down => self.down(p, ev.mods, tol), + PointerKind::Drag => self.drag(p, ev.mods, tol), + PointerKind::Up => self.up(p, ev.mods), + PointerKind::DoubleClick => self.double_click(p, tol), + PointerKind::TripleClick => { + if self.active().is_some_and(|d| d.selection.text.is_some()) { + self.execute("text.selectParagraph", &json!({})) + } else { + Ok(Value::Null) + } + } + } + } + + fn begin(&mut self, label: &str) { + if let Some(st) = self.active_mut() + && st.interaction.is_none() + { + st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone() }); + } + } + + fn commit(&mut self) { + if let Some(st) = self.active_mut() + && let Some(i) = st.interaction.take() + && !Arc::ptr_eq(&i.doc, &st.doc) + { + crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection }); + } + } + + fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + match self.tool.kind.clone() { + ToolKind::Shape { .. } | ToolKind::TextBox => { + self.tool.drag = Some(Drag::Create { start: p }); + self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); + return Ok(Value::Null); + } + ToolKind::Ink { mode, .. } => { + if mode == "eraser" { + return self.erase_at(p, tol); + } + self.tool.drag = Some(Drag::Ink { points: vec![(p.x, p.y, 0.5)] }); + self.tool.ink_preview = vec![(p.x, p.y, 0.5)]; + return Ok(Value::Null); + } + ToolKind::Select => {} + } + let st = self.doc()?; + // Clicking inside the text being edited moves the caret / starts a text selection. + if let Some(t) = st.selection.text.clone() + && !t.notes + && t.cell.is_none() + && let Some(sh) = st.shape(t.shape) + { + let x = xfrm_of(&st.doc, &st.selection, sh); + if x.bounds().inflate(tol, tol).contains(p) + && !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. })) + { + let pos = text_pos(st, sh, p); + let shift = mods.shift; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.caret = pos; + if !shift { + tt.anchor = pos; + } + } + })?; + self.tool.drag = Some(Drag::TextSelect { shape: t.shape }); + return Ok(json!({"caret": [pos.0, pos.1]})); + } + } + let hit = self.hit_test(p, tol); + let st = self.doc()?; + match hit { + Some(Hit::Resize { id, handle }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Size"); + self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p }); + } + Some(Hit::LineEnd { id, end }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Size"); + self.tool.drag = Some(Drag::LineEnd { id, end, orig }); + } + Some(Hit::Rotate { id }) => { + let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] }; + let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + let center = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s).center()).unwrap_or(p); + let start = (p.y - center.y).atan2(p.x - center.x); + self.begin("Rotate"); + self.tool.drag = Some(Drag::Rotate { ids, center, start, origs }); + } + Some(Hit::Adjust { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Adjust Shape"); + self.tool.drag = Some(Drag::Adjust { id, index, orig }); + } + Some(Hit::Shape { id, text }) => { + let selected = st.selection.shapes.contains(&id); + let toggle = mods.shift || mods.cmd; + let ids: Vec = if toggle { + let mut v = st.selection.shapes.clone(); + if selected { + v.retain(|x| *x != id); + } else { + v.push(id); + } + v + } else if selected { + st.selection.shapes.clone() + } else { + vec![id] + }; + let origs: Vec<(ShapeId, Xfrm)> = ids.iter().filter_map(|i| st.shape(*i).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + // A click (no drag) on text puts the caret there; unselected text boxes and + // placeholders take the caret straight away like PowerPoint. + let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p)); + let ids2 = ids.clone(); + self.select(|_, sel| { + sel.shapes = ids2; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate: mods.alt, enter_text }); + // Format Painter applies on click. + if self.painter.is_some() && !toggle { + let _ = self.execute("format.painterApply", &json!({"ids": [id]})); + } + } + None => { + let add = mods.shift || mods.cmd; + self.select(|_, sel| { + sel.text = None; + sel.cells = None; + if !add { + sel.shapes.clear(); + } + })?; + self.tool.drag = Some(Drag::Marquee { start: p, add }); + self.tool.marquee = Some(Rect::from_points(p, p)); + } + } + Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()})) + } + + fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; + match drag { + Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { + let mut d = p - start; + if !moved && d.hypot() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if duplicate { "Duplicate" } else { "Move" }); + if duplicate { + // Alt-drag: drag a copy, leave the original. + self.execute_dup_in_place(&ids)?; + } + } + let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() }; + if mods.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Smart guides on the moved group's bounds. + let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default(); + let moved_b = bounds + d; + let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd); + d += snap; + self.tool.guides = guides; + let origs2: Vec<(ShapeId, Xfrm)> = + if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?; + for (id, o) in &origs2 { + if let Some(sh) = slidecraft_model::find_shape_mut(list, *id) { + sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); + } + } + Ok(()) + })?; + self.tool.drag = + Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); + } + Drag::Resize { id, handle, orig, start } => { + let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt); + let nx = self.snap_resize(nx, &orig, handle, id, tol); + self.set_xfrm(id, nx)?; + } + Drag::LineEnd { id, end, orig } => { + let a = orig.affine(); + let (p0, p1) = (a * Point::new(0.0, 0.0), a * Point::new(orig.w, orig.h)); + let mut q = p; + let other = if end == 0 { p1 } else { p0 }; + if mods.shift { + q = constrain_45(other, q); + } + let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) }; + self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?; + } + Drag::Rotate { ids, center, start, origs } => { + let ang = (p.y - center.y).atan2(p.x - center.x); + let mut delta = (ang - start).to_degrees(); + for (id, o) in &origs { + let mut r = o.rot + delta; + if mods.shift { + r = (r / 15.0).round() * 15.0; + delta = r - o.rot; + } + let mut nx = *o; + nx.rot = ((r % 360.0) + 360.0) % 360.0; + if ids.len() > 1 { + // Rotate positions around the common centre too. + let c = o.center(); + let rc = Affine::translate(center.to_vec2()) * Affine::rotate(delta.to_radians()) * Affine::translate(-center.to_vec2()) * c; + nx.x = rc.x - o.w / 2.0; + nx.y = rc.y - o.h / 2.0; + } + self.set_xfrm(*id, nx)?; + } + } + Drag::Adjust { id, index, orig } => { + let st = self.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; + let local = orig.affine().inverse() * p; + let geo = slidecraft_render::shape_geometry(&sh, orig.w, orig.h); + let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow")); + let vals: Vec<(usize, f64)> = if free2d { + let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5; + let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5; + vec![(0, dx.round()), (1, dy.round())] + } else { + vec![(index, h.value_at(local))] + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = slidecraft_model::find_shape_mut(list, id) + && let slidecraft_model::Geom::Preset { name, adj } = &mut sh.geom + { + let defaults = slidecraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); + for (i, v) in &vals { + while adj.len() <= *i { + let k = adj.len(); + adj.push(defaults.get(k).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(*i) { + *slot = *v; + } + } + } + Ok(()) + })?; + } + Drag::Marquee { start, .. } => { + self.tool.marquee = Some(Rect::from_points(start, p)); + } + Drag::Create { start } => { + let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if slidecraft_geom::preset::is_line_like(preset)); + let x = if line { + let q = if mods.shift { constrain_45(start, p) } else { p }; + line_xfrm(start, q, 0.0) + } else { + let mut r = Rect::from_points(start, p); + if mods.shift { + let s = r.width().max(r.height()); + let sx = if p.x < start.x { -s } else { s }; + let sy = if p.y < start.y { -s } else { s }; + r = Rect::from_points(start, start + Vec2::new(sx, sy)); + } + if mods.alt { + let d = p - start; + r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs()); + } + Xfrm::new(r.x0, r.y0, r.width(), r.height()) + }; + self.tool.preview = Some(x); + } + Drag::TextSelect { shape } => { + let st = self.doc()?; + if let Some(sh) = st.shape(shape) { + let pos = text_pos(st, sh, p); + self.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.caret = pos; + } + })?; + } + } + Drag::Ink { mut points } => { + points.push((p.x, p.y, 0.5)); + self.tool.ink_preview = points.clone(); + self.tool.drag = Some(Drag::Ink { points }); + } + } + Ok(Value::Null) + } + + fn up(&mut self, p: Point, mods: Mods) -> Result { + let drag = self.tool.drag.take(); + self.tool.guides.clear(); + let mut out = Value::Null; + match drag { + Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { + // Plain click on text: caret at the click. + let st = self.doc()?; + if let Some(sh) = st.shape(id) { + let pos = text_pos(st, sh, at); + self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; + out = json!({"editing": id}); + } + } + Some(Drag::Marquee { start, add }) => { + let r = Rect::from_points(start, p); + self.tool.marquee = None; + if r.width() > 1.0 || r.height() > 1.0 { + let st = self.doc()?; + let ids: Vec = st + .shapes() + .iter() + .filter(|s| !s.hidden && !s.locked) + .filter(|s| contains_rect(r, xfrm_of(&st.doc, &st.selection, s).bounds())) + .map(|s| s.id) + .collect(); + self.select(|_, sel| { + if add { + for i in &ids { + if !sel.shapes.contains(i) { + sel.shapes.push(*i); + } + } + } else { + sel.shapes = ids.clone(); + } + })?; + out = json!({"selected": ids}); + } + } + Some(Drag::Create { start }) => { + let kind = self.tool.kind.clone(); + let preview = self.tool.preview.take(); + let dragged = (p - start).hypot() > 2.0; + let res = match &kind { + ToolKind::Shape { preset } => { + let line = slidecraft_geom::preset::is_line_like(preset); + let rect = match preview.filter(|_| dragged) { + Some(x) => x, + None if line => Xfrm::new(start.x, start.y, 72.0, 0.0), + None => Xfrm::new(start.x, start.y, 72.0, 72.0), + }; + let r = self.execute("shape.insert", &json!({"preset": preset, "rect": [rect.x, rect.y, rect.w, rect.h], "flip": format!("{}{}", if rect.flip_h { "h" } else { "" }, if rect.flip_v { "v" } else { "" })}))?; + if let (Some(id), Some(x)) = (r.get("id").and_then(Value::as_u64), preview.filter(|_| dragged && line)) { + let _ = self.set_xfrm_cmd(ShapeId(id as u32), x); + } + r + } + ToolKind::TextBox => { + let w = preview.filter(|_| dragged).map(|x| x.w).unwrap_or(0.0); + let at = preview.filter(|_| dragged).map(|x| Point::new(x.x, x.y)).unwrap_or(start); + self.execute("insert.textBox", &json!({"rect": [at.x, at.y, w, 30.0]}))? + } + _ => Value::Null, + }; + if !self.tool.sticky { + self.tool.kind = ToolKind::Select; + } + out = res; + } + Some(Drag::Ink { points }) => { + self.tool.ink_preview.clear(); + if points.len() > 1 { + self.begin("Ink"); + out = self.add_ink(points)?; + } + } + Some(Drag::Resize { id, .. }) => { + self.commit(); + let _ = cmd::text::fit_text_box(self, id); + self.refit_after_resize(id)?; + } + Some(_) => self.commit(), + None => {} + } + // Any open interaction (move etc.) ends here. + self.commit(); + let _ = mods; + Ok(out) + } + + fn double_click(&mut self, p: Point, tol: f64) -> Result { + if !matches!(self.tool.kind, ToolKind::Select) { + // Double-click while drawing: lock drawing mode off. + self.tool.sticky = false; + return Ok(Value::Null); + } + let st = self.doc()?; + if st.selection.text.is_some() { + return self.execute("text.selectWord", &json!({})); + } + match self.hit_test(p, tol) { + Some(Hit::Shape { id, .. }) => { + let st = self.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; + match &sh.kind { + ShapeKind::Table(_) => { + let x = xfrm_of(&st.doc, &st.selection, &sh); + let local = x.affine().inverse() * p; + if let ShapeKind::Table(t) = &sh.kind { + let cell = slidecraft_render_cell(t, local); + return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]})); + } + Ok(Value::Null) + } + ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + let pos = text_pos(st, &sh, p); + self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?; + self.execute("text.selectWord", &json!({})) + } + ShapeKind::Group { .. } => Ok(Value::Null), + _ => { + self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); + Ok(Value::Null) + } + } + } + _ => Ok(Value::Null), + } + } + + fn is_picture(&self, id: ShapeId) -> bool { + self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) + } + + fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> { + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?; + if let Some(sh) = slidecraft_model::find_shape_mut(list, id) { + sh.xfrm = Some(x); + if let ShapeKind::Table(t) = &mut sh.kind { + // Tables resize their columns and rows proportionally. + let (tw, th) = (t.width().max(1e-6), t.height().max(1e-6)); + let (kx, ky) = (x.w / tw, x.h / th); + t.cols.iter_mut().for_each(|c| *c *= kx); + t.rows.iter_mut().for_each(|r| r.height *= ky); + } + } + Ok(()) + }) + } + + fn set_xfrm_cmd(&mut self, id: ShapeId, x: Xfrm) -> Result { + self.execute("shape.setBounds", &json!({"id": id, "x": x.x, "y": x.y, "w": x.w, "h": x.h}))?; + if x.flip_h || x.flip_v { + self.edit(|doc, sel| { + if let Some(sh) = crate::shapes_mut(doc, sel).and_then(|l| slidecraft_model::find_shape_mut(l, id)) + && let Some(xx) = sh.xfrm.as_mut() + { + xx.flip_h = x.flip_h; + xx.flip_v = x.flip_v; + } + Ok(()) + })?; + } + Ok(Value::Null) + } + + fn refit_after_resize(&mut self, id: ShapeId) -> Result<()> { + // A text box resized by hand keeps wrapping at its new width. + let st = self.doc()?; + if st.shape(id).is_some_and(|s| s.text_box) { + let _ = cmd::text::fit_text_box(self, id); + } + Ok(()) + } + + fn execute_dup_in_place(&mut self, ids: &[ShapeId]) -> Result<()> { + self.edit(|doc, sel| { + let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); + let mut copies: Vec = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); + let boxes: Vec = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); + cmd::edit::reid(doc, &mut copies); + for (c, b) in copies.iter_mut().zip(boxes) { + c.xfrm = Some(b); + c.ph = None; + } + let new_ids: Vec = copies.iter().map(|c| c.id).collect(); + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("duplicate", "no slide"))?; + shapes.extend(copies); + sel.shapes = new_ids; + Ok(()) + }) + } + + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to + /// apply and the guides to draw. + fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { + let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) }; + if !enabled || !self.prefs.smart_guides { + return (Vec2::ZERO, vec![]); + } + let size = st.doc.slide_size; + let mut xs: Vec<(f64, Rect)> = vec![ + (0.0, Rect::new(0.0, 0.0, 0.0, size.height)), + (size.width / 2.0, Rect::new(size.width / 2.0, 0.0, size.width / 2.0, size.height)), + (size.width, Rect::new(size.width, 0.0, size.width, size.height)), + ]; + let mut ys: Vec<(f64, Rect)> = vec![ + (0.0, Rect::new(0.0, 0.0, size.width, 0.0)), + (size.height / 2.0, Rect::new(0.0, size.height / 2.0, size.width, size.height / 2.0)), + (size.height, Rect::new(0.0, size.height, size.width, size.height)), + ]; + for sh in st.shapes() { + if exclude.contains(&sh.id) || sh.hidden { + continue; + } + let b = xfrm_of(&st.doc, &st.selection, sh).bounds(); + for x in [b.x0, b.center().x, b.x1] { + xs.push((x, b)); + } + for y in [b.y0, b.center().y, b.y1] { + ys.push((y, b)); + } + } + let mut best_x: Option<(f64, f64, Rect)> = None; + for edge in [r.x0, r.center().x, r.x1] { + for (x, b) in &xs { + let d = x - edge; + if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { + best_x = Some((d, *x, *b)); + } + } + } + let mut best_y: Option<(f64, f64, Rect)> = None; + for edge in [r.y0, r.center().y, r.y1] { + for (y, b) in &ys { + let d = y - edge; + if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) { + best_y = Some((d, *y, *b)); + } + } + } + let mut off = Vec2::ZERO; + let mut guides = vec![]; + if let Some((d, x, b)) = best_x { + off.x = d; + let rr = r + Vec2::new(d, 0.0); + guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) }); + } else if self.prefs.snap_to_grid { + off.x = slidecraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0; + } + if let Some((d, y, b)) = best_y { + off.y = d; + let rr = r + Vec2::new(0.0, d); + guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) }); + } else if self.prefs.snap_to_grid { + off.y = slidecraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0; + } + (off, guides) + } + + fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm { + if x.rot != 0.0 { + self.tool.guides.clear(); + return x; + } + let r = x.rect(); + let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true); + let mut nx = x; + // Only the dragged edges move. + let moves_left = matches!(handle, 0 | 6 | 7); + let moves_right = matches!(handle, 2..=4); + let moves_top = matches!(handle, 0..=2); + let moves_bottom = matches!(handle, 4..=6); + if moves_left { + nx.x += off.x; + nx.w -= off.x; + } else if moves_right { + nx.w += off.x; + } + if moves_top { + nx.y += off.y; + nx.h -= off.y; + } else if moves_bottom { + nx.h += off.y; + } + nx.w = nx.w.max(0.0); + nx.h = nx.h.max(0.0); + self.tool.guides = guides; + nx + } + + fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { + let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; + let stroke = slidecraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; + // Strokes join the slide's ink layer (one ink shape per slide). + let existing = self.doc()?.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id); + self.edit(|doc, sel| { + let id = match existing { + Some(id) => id, + None => { + let id = ShapeId(doc.alloc_id()); + let size = doc.slide_size; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + list.push(Shape { + id, + name: "Ink".into(), + xfrm: Some(Xfrm::new(0.0, 0.0, size.width, size.height)), + kind: ShapeKind::Ink { strokes: vec![] }, + ..Default::default() + }); + id + } + }; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + if let Some(sh) = slidecraft_model::find_shape_mut(list, id) + && let ShapeKind::Ink { strokes } = &mut sh.kind + { + strokes.push(stroke); + } + Ok(json!({"ink": id})) + }) + } + + fn erase_at(&mut self, p: Point, tol: f64) -> Result { + let st = self.doc()?; + let Some(ink) = st.shapes().iter().rev().find(|s| matches!(s.kind, ShapeKind::Ink { .. })).map(|s| s.id) else { return Ok(Value::Null) }; + let before = st.doc.clone(); + let sel_before = st.selection.clone(); + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + if let Some(sh) = slidecraft_model::find_shape_mut(list, ink) + && let ShapeKind::Ink { strokes } = &mut sh.kind + { + strokes.retain(|s| !s.points.iter().any(|(x, y, _)| (Point::new(*x, *y) - p).hypot() <= tol * 2.0 + s.width)); + } + Ok(()) + })?; + if let Some(st) = self.active_mut() { + crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before }); + } + Ok(Value::Null) + } +} + +fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { + st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) +} + +fn contains_rect(outer: Rect, inner: Rect) -> bool { + inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 +} + +fn constrain_45(a: Point, b: Point) -> Point { + let d = b - a; + let ang = d.y.atan2(d.x); + let snapped = (ang / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4; + let len = d.hypot(); + a + Vec2::new(snapped.cos(), snapped.sin()) * len +} + +/// A line-like shape's box for endpoints `a` → `b` (flips encode direction). +pub fn line_xfrm(a: Point, b: Point, _rot: f64) -> Xfrm { + let r = Rect::from_points(a, b); + Xfrm { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rot: 0.0, flip_h: b.x < a.x, flip_v: b.y < a.y } +} + +/// New box when dragging resize handle `h` by `d` (slide space), keeping the shape's rotation. +pub fn resize_box(o: &Xfrm, h: u8, d: Vec2, keep_aspect: bool, from_center: bool) -> Xfrm { + // Work in the shape's rotated frame. + let rot = Affine::rotate(-o.rot.to_radians()); + let ld = rot * Point::new(d.x, d.y); + let (mut l, mut t, mut r, mut b) = (0.0, 0.0, o.w, o.h); + let (sx, sy) = (if o.flip_h { -1.0 } else { 1.0 }, if o.flip_v { -1.0 } else { 1.0 }); + let (dx, dy) = (ld.x * sx, ld.y * sy); + match h { + 0 => { + l += dx; + t += dy; + } + 1 => t += dy, + 2 => { + r += dx; + t += dy; + } + 3 => r += dx, + 4 => { + r += dx; + b += dy; + } + 5 => b += dy, + 6 => { + l += dx; + b += dy; + } + _ => l += dx, + } + if from_center { + // Symmetric growth about the centre. + let gw = match h { + 0 | 6 | 7 => -dx, + 2..=4 => dx, + _ => 0.0, + }; + let gh = match h { + 0..=2 => -dy, + 4..=6 => dy, + _ => 0.0, + }; + l = -gw; + r = o.w + gw; + t = -gh; + b = o.h + gh; + } + let mut w = r - l; + let mut hh = b - t; + if keep_aspect && o.w > 1e-9 && o.h > 1e-9 && !matches!(h, 1 | 3 | 5 | 7) { + let ar = o.w / o.h; + if (w / ar).abs() > hh.abs() { + hh = w.abs() / ar * hh.signum().max(if hh == 0.0 { 1.0 } else { hh.signum() }); + } else { + w = hh.abs() * ar * w.signum().max(if w == 0.0 { 1.0 } else { w.signum() }); + } + if matches!(h, 0 | 6) { + l = r - w; + } else { + r = l + w; + } + if matches!(h, 0 | 2) { + t = b - hh; + } else { + b = t + hh; + } + if from_center { + let (cx, cy) = (o.w / 2.0, o.h / 2.0); + l = cx - w / 2.0; + r = cx + w / 2.0; + t = cy - hh / 2.0; + b = cy + hh / 2.0; + } + } + // Dragging past the opposite edge flips. + let (mut fh, mut fv) = (o.flip_h, o.flip_v); + if r < l { + std::mem::swap(&mut l, &mut r); + fh = !fh; + } + if b < t { + std::mem::swap(&mut t, &mut b); + fv = !fv; + } + let (nw, nh) = (r - l, b - t); + // New centre in slide space: old centre + rotated offset of the local centre change. + let local_c = Point::new((l + r) / 2.0 * sx + if sx < 0.0 { o.w } else { 0.0 }, (t + b) / 2.0 * sy + if sy < 0.0 { o.h } else { 0.0 }); + let c = o.affine() * Point::new(if o.flip_h { o.w - local_c.x } else { local_c.x }, if o.flip_v { o.h - local_c.y } else { local_c.y }); + Xfrm { x: c.x - nw / 2.0, y: c.y - nh / 2.0, w: nw, h: nh, rot: o.rot, flip_h: fh, flip_v: fv } +} + +fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { + let x = xfrm_of(&st.doc, &st.selection, sh); + let inv = x.affine().inverse(); + let local = inv * p; + match &sh.kind { + ShapeKind::Group { children, child } => { + // A group is hit when any child is hit; the group is what gets selected. + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let m = slidecraft_geom::group_child_affine(&x, ch); + let q = m.inverse() * p; + for c in children.iter().rev() { + if let Some(cx) = c.xfrm { + let cl = cx.affine().inverse() * q; + if cl.x >= -tol && cl.y >= -tol && cl.x <= cx.w + tol && cl.y <= cx.h + tol { + return Some(Hit::Shape { id: sh.id, text: false }); + } + } + } + None + } + ShapeKind::Ink { strokes } => { + for s in strokes { + if s.points.iter().any(|(px, py, _)| (Point::new(*px, *py) - p).hypot() <= tol + s.width) { + return Some(Hit::Shape { id: sh.id, text: false }); + } + } + None + } + _ => { + if sh.is_line() { + let geo = slidecraft_render::shape_geometry(sh, x.w, x.h); + let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0); + if slidecraft_geom::near_path(&geo.outline(), local, tol.max(lw)) { + return Some(Hit::Shape { id: sh.id, text: false }); + } + return None; + } + let inside = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if !inside { + return None; + } + let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box); + // Empty interiors of unfilled, text-less shapes don't catch clicks. + let filled = !matches!(sh.fill, Some(slidecraft_model::Fill::None)) || sh.style.is_some(); + let geo = slidecraft_render::shape_geometry(sh, x.w, x.h); + let in_geo = { + use slidecraft_geom::KurboShape; + geo.outline().winding(local) != 0 || slidecraft_geom::near_path(&geo.outline(), local, tol) + }; + if has_text + || sh.ph.is_some() + || matches!( + sh.kind, + ShapeKind::Picture { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Media(_) | ShapeKind::Opaque { .. } + ) + || (filled && in_geo) + || slidecraft_geom::near_path(&geo.outline(), local, tol) + { + return Some(Hit::Shape { id: sh.id, text: has_text || matches!(sh.kind, ShapeKind::Shape) }); + } + None + } + } +} + +/// Text position under slide point `p` in shape `sh`. +pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) { + let x = xfrm_of(&st.doc, &st.selection, sh); + let local = x.affine().inverse() * p; + let t = TextSel { shape: sh.id, ..Default::default() }; + match cmd::text::layout_for(st, &t) { + Some(l) => { + let h = l.hit(local); + (h.para, h.ch) + } + None => (0, 0), + } +} + +/// Which table cell contains a table-local point. +fn slidecraft_render_cell(t: &slidecraft_model::Table, p: Point) -> (usize, usize) { + let mut y = 0.0; + let mut row = t.rows.len().saturating_sub(1); + for (i, r) in t.rows.iter().enumerate() { + if p.y < y + r.height { + row = i; + break; + } + y += r.height; + } + let mut x = 0.0; + let mut col = t.cols.len().saturating_sub(1); + for (i, w) in t.cols.iter().enumerate() { + if p.x < x + w { + col = i; + break; + } + x += w; + } + (row, col) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resize_corner_and_flip() { + let o = Xfrm::new(100.0, 100.0, 100.0, 50.0); + let n = resize_box(&o, 4, Vec2::new(20.0, 10.0), false, false); + assert!((n.x - 100.0).abs() < 1e-9 && (n.w - 120.0).abs() < 1e-9 && (n.h - 60.0).abs() < 1e-9); + let n = resize_box(&o, 0, Vec2::new(10.0, 10.0), false, false); + assert!((n.x - 110.0).abs() < 1e-9 && (n.w - 90.0).abs() < 1e-9); + let n = resize_box(&o, 3, Vec2::new(-150.0, 0.0), false, false); + assert!(n.flip_h && (n.w - 50.0).abs() < 1e-9 && (n.x - 50.0).abs() < 1e-9); + let n = resize_box(&o, 4, Vec2::new(100.0, 0.0), true, false); + assert!((n.w / n.h - 2.0).abs() < 1e-9); + let n = resize_box(&o, 3, Vec2::new(10.0, 0.0), false, true); + assert!((n.w - 120.0).abs() < 1e-9 && (n.x - 90.0).abs() < 1e-9); + } + + #[test] + fn resize_rotated_keeps_opposite_corner() { + let o = Xfrm { x: 100.0, y: 100.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() }; + let anchor = o.affine() * Point::new(0.0, 0.0); + let n = resize_box(&o, 4, Vec2::new(-10.0, 30.0), false, false); + let anchor2 = n.affine() * Point::new(0.0, 0.0); + assert!((anchor - anchor2).hypot() < 1e-6, "{anchor:?} {anchor2:?}"); + } +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml new file mode 100644 index 0000000..11194a1 --- /dev/null +++ b/crates/fonts/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "slidecraft-fonts" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft fonts: font database (craft-fonts + system), substitutes for Office fonts, metrics, outlines, shaping" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +skrifa = { workspace = true } +harfrust = { workspace = true } +unicode-bidi = { workspace = true } +log = { workspace = true } +# Last-resort face only (Ubuntu Light); no UI code. +epaint_default_fonts = "0.36" diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs new file mode 100644 index 0000000..0e351e0 --- /dev/null +++ b/crates/fonts/build.rs @@ -0,0 +1,74 @@ +//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its +//! `docs/integration.md`). With `CRAFT_FONTS_DIR=` the fonts in its +//! `fonts/latin-manifest.txt` (presentation fonts) and `fonts/manifest.txt` are embedded as +//! `CRAFT_FONTS`, Latin first; unset, `CRAFT_FONTS` is empty. Web (wasm32) builds embed only Inter +//! Regular/Bold and BIZ UDPGothic Regular, to stay within the web size budget. It only reads the +//! local checkout: no network. +use std::fmt::Write as _; +use std::path::PathBuf; + +/// Families SlideCraft never embeds (AGENTS.md §1: no Adobe-authored visual design). +const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif", "Noto Sans CJK SC"]; + +fn main() { + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + match craft_fonts(&dir) { + Ok(entries) => src.push_str(&entries), + Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { + println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + } + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + src.push_str("];\n"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + if let Err(e) = std::fs::write(&out, src) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// One `CraftFont { .. }` initialiser per manifest line. +fn craft_fonts(dir: &std::path::Path) -> Result { + let mut out = String::new(); + // The Latin presentation fonts are optional within craft-fonts (older checkouts lack them). + let latin = dir.join("fonts/latin-manifest.txt"); + println!("cargo::rerun-if-changed={}", latin.display()); + if latin.exists() { + out.push_str(&manifest(dir, &latin)?); + } + out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?); + Ok(out) +} + +fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result { + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = String::new(); + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + let [family, style, file, scripts, ..] = f.as_slice() else { + return Err(format!("malformed manifest line: {line}")); + }; + // SlideCraft's asset policy: no Adobe-authored typefaces, even openly licensed ones. + if EXCLUDED_FAMILIES.contains(family) { + continue; + } + if wasm && !matches!((*family, *style), ("BIZ UDPGothic", "Regular") | ("Inter", "Regular" | "Bold" | "Variable")) { + continue; + } + let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; + println!("cargo::rerun-if-changed={}", path.display()); + let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); + let _ = writeln!( + out, + " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},", + scripts.join(", "), + path.display().to_string(), + ); + } + Ok(out) +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs new file mode 100644 index 0000000..89e85e2 --- /dev/null +++ b/crates/fonts/src/fontdb.rs @@ -0,0 +1,923 @@ +//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged the first +//! time a lookup needs them, native only), outline cache. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +use kurbo::BezPath; +use skrifa::instance::{Location, LocationRef, Size}; +use skrifa::outline::{DrawSettings, OutlinePen}; +use skrifa::raw::FileRef; +use skrifa::string::StringId; +use skrifa::{GlyphId, MetadataProvider}; + +/// The family used when a requested family is unknown and has no substitute. +pub const FALLBACK_FAMILY: &str = "Inter"; + +/// The always-available last-resort face (Ubuntu Light, compiled in through +/// `epaint_default_fonts`; licence in ATTRIBUTION.md). +pub const LAST_RESORT_FAMILY: &str = "Ubuntu"; + +/// Compiled-in font files: the craft-fonts faces (Latin presentation fonts, then Japanese; +/// empty without `CRAFT_FONTS_DIR`), then the last-resort face. +pub fn bundled() -> Vec<&'static [u8]> { + let mut v: Vec<&'static [u8]> = crate::CRAFT_FONTS.iter().map(|f| f.bytes).collect(); + v.push(epaint_default_fonts::UBUNTU_LIGHT); + v +} + +/// Open substitutes for families documents often ask for but we can't ship (metric-compatible +/// where one exists), tried in order after the family itself. +pub fn substitutes(family: &str) -> &'static [&'static str] { + match family.to_ascii_lowercase().as_str() { + "calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"], + "cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"], + "arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"], + "times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"], + "courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"], + "aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => { + &["Inter", "Helvetica Neue", "Arial", "Liberation Sans"] + } + "georgia" | "garamond" | "book antiqua" | "palatino" | "palatino linotype" | "constantia" => &["Merriweather", "Liberation Serif", "Georgia"], + "century gothic" | "avenir" | "avenir next" | "futura" | "gill sans" | "gill sans mt" | "trebuchet ms" | "corbel" | "candara" => { + &["Montserrat", "Poppins", "Avenir Next", "Inter"] + } + "franklin gothic" | "franklin gothic book" | "franklin gothic medium" | "univers" => &["Open Sans", "Inter"], + "open sans" | "lato" | "roboto" | "montserrat" | "poppins" | "nunito sans" => &["Inter"], + "playfair display" | "merriweather" | "source serif 4" | "source serif pro" => &["Merriweather", "Liberation Serif", "Georgia"], + "source sans 3" | "source sans pro" => &["Open Sans", "Inter"], + "impact" | "arial black" => &["Montserrat", "Inter"], + _ => &[], + } +} + +#[derive(Clone)] +enum FontBytes { + Static(&'static [u8]), + Owned(Arc>), +} + +/// One loaded font face. +pub struct FontFace { + id: u32, + /// Typographic family name (e.g. "Source Sans 3"). + pub family: String, + /// Typographic style name (e.g. "Semibold", "Italic"). + pub style: String, + /// usWeightClass-style weight (400 = regular). + pub weight: f32, + pub italic: bool, + bytes: FontBytes, + index: u32, + pub upem: f64, + /// Ascender in font units (positive = up). + pub ascent: f64, + /// Descender in font units (positive = down). + pub descent: f64, + /// Cap height and x height in font units (estimated from the ascent when the font has no OS/2 + /// values). + pub cap_height: f64, + pub x_height: f64, + pub shaper: harfrust::ShaperData, + /// Variable fonts: the named instance's axis settings (user units; empty = default instance), + /// the normalised location and the shaper's view of it. + pub coords: Vec<([u8; 4], f32)>, + location: Location, + pub instance: Option, + /// Basic Multilingual Plane coverage bitset, built on first use. + bmp: std::sync::OnceLock>, +} + +/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of +/// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made +/// parallel composition scale negatively). +#[derive(Clone, Copy)] +pub struct FaceRef(&'static FontFace); + +impl FaceRef { + /// The handle for a loaded face (memoised per face). + pub fn of(face: &Arc) -> FaceRef { + static LEAKED: RwLock>> = RwLock::new(Vec::new()); + let id = face.id as usize; + if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) { + return FaceRef(f); + } + let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner()); + if w.len() <= id { + w.resize(id + 1, None); + } + let f: &'static FontFace = w[id].unwrap_or_else(|| { + let keep: &'static Arc = Box::leak(Box::new(face.clone())); + keep + }); + w[id] = Some(f); + FaceRef(f) + } + pub fn get(self) -> &'static FontFace { + self.0 + } +} + +impl std::ops::Deref for FaceRef { + type Target = FontFace; + fn deref(&self) -> &FontFace { + self.0 + } +} + +impl std::fmt::Debug for FaceRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl PartialEq for FaceRef { + fn eq(&self, o: &FaceRef) -> bool { + self.0.id == o.0.id + } +} + +impl std::fmt::Debug for FontFace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "FontFace({} {})", self.family, self.style) + } +} + +impl FontFace { + pub fn data(&self) -> &[u8] { + match &self.bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + } + } + pub fn skrifa(&self) -> Option> { + skrifa::FontRef::from_index(self.data(), self.index).ok() + } + pub fn hb(&self) -> Option> { + harfrust::FontRef::from_index(self.data(), self.index).ok() + } + /// Where in the design space glyphs, metrics and outlines come from. + pub fn location(&self) -> LocationRef<'_> { + (&self.location).into() + } + /// Is this a named instance of a variable font? + pub fn is_variable(&self) -> bool { + !self.coords.is_empty() + } + /// Face index within the font file (collections); 0 for plain fonts. + pub fn index(&self) -> u32 { + self.index + } + /// Unique id of this face within the process. + pub fn id(&self) -> u32 { + self.id + } + /// Does the face map `c` to a glyph? + pub fn covers(&self, c: char) -> bool { + let cp = c as u32; + if cp < 0x1_0000 { + let bits = self.bmp.get_or_init(|| { + let mut b = vec![0u64; 1024].into_boxed_slice(); + if let Some(f) = self.skrifa() { + for (cp, _) in f.charmap().mappings() { + if cp < 0x1_0000 { + b[(cp / 64) as usize] |= 1 << (cp % 64); + } + } + } + b + }); + return bits[(cp / 64) as usize] & (1 << (cp % 64)) != 0; + } + self.skrifa().is_some_and(|f| f.charmap().map(c).is_some()) + } + /// Units per em. + pub fn units_per_em(&self) -> f64 { + self.upem + } + /// (ascent, descent) in font units, both positive. + pub fn vertical_metrics(&self) -> (f64, f64) { + (self.ascent, self.descent) + } + /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). + pub fn chars(&self) -> Vec<(char, u32)> { + let Some(f) = self.skrifa() else { return vec![] }; + let mut v: Vec<(char, u32)> = f.charmap().mappings().filter_map(|(cp, g)| char::from_u32(cp).map(|c| (c, g.to_u32()))).collect(); + v.sort_unstable_by_key(|x| x.0); + v.dedup_by_key(|x| x.0); + v + } + /// Advance width of glyph `gid` in font units. + pub fn advance(&self, gid: u32) -> f64 { + self.skrifa() + .and_then(|f| f.glyph_metrics(Size::unscaled(), self.location()).advance_width(GlyphId::new(gid))) + .map(|a| a as f64) + .unwrap_or(self.upem * 0.5) + } + /// Glyph id for `c` (0 = .notdef). + pub fn glyph_for(&self, c: char) -> u32 { + self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0) + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +struct CatalogEntry { + family: String, + style: String, + path: std::path::PathBuf, +} + +/// Process-wide font database. +pub struct FontDb { + faces: RwLock>>, + outlines: Mutex>>, + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock>, + /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + font_dirs: Vec, + /// Set once the font folders have been scanned. Lookups by family name wait for the first + /// scan, so what they find doesn't depend on what ran before them. + #[cfg(not(target_arch = "wasm32"))] + cataloged: std::sync::OnceLock<()>, + /// System fallback state: enabled, and characters no system font covers. + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex, +} + +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct SysFallback { + enabled: bool, + misses: std::collections::HashSet, +} + +/// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. +#[cfg(not(target_arch = "wasm32"))] +const SYSTEM_FALLBACKS: &[&str] = &[ + "Helvetica Neue", + "Arial", + "Segoe UI", + "Noto Sans", + "DejaVu Sans", + "PingFang SC", + "Hiragino Sans", + "Hiragino Kaku Gothic ProN", + "Apple SD Gothic Neo", + "Heiti SC", + "STHeiti", + "Microsoft YaHei", + "Yu Gothic", + "Malgun Gothic", + "Noto Sans CJK SC", + "Noto Sans CJK JP", + "Arial Unicode MS", + "Apple Symbols", + "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols2", + "Noto Emoji", + "Segoe UI Emoji", + "Apple Color Emoji", + "Noto Color Emoji", +]; + +static NEXT_ID: AtomicU32 = AtomicU32::new(1); +const OUTLINE_CACHE_MAX: usize = 50_000; + +fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { + ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) +} + +/// A face's family and style names (the default instance's style for a variable font). +fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { + let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((family, style)) +} + +/// (family, style) of every face in the font file at `path`, reading only its table directories +/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. A +/// variable font is cataloged under its default style; its named instances appear once it loads. +#[cfg(not(target_arch = "wasm32"))] +fn file_face_names(path: &std::path::Path) -> Vec<(String, String)> { + use std::io::{Read, Seek, SeekFrom}; + /// Caps on what a (possibly damaged) file can make the scan read. + const MAX_FACES: u32 = 256; + const MAX_NAME_TABLE: u32 = 1 << 20; + let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; + let mut read_at = |offset: u64, len: usize| -> Option> { + let mut buf = vec![0; len]; + file.seek(SeekFrom::Start(offset)).ok()?; + file.read_exact(&mut buf).ok()?; + Some(buf) + }; + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let Some(head) = read_at(0, 12) else { return vec![] }; + // A collection lists where each face's table directory starts. + let starts: Vec = if head.starts_with(b"ttcf") { + let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; + read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default() + } else { + vec![0] + }; + starts + .into_iter() + .filter_map(|start| { + let dir = read_at(start.into(), 12)?; + let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; + let records = read_at(u64::from(start) + 12, tables * 16)?; + let rec = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > MAX_NAME_TABLE { + return None; + } + let table = read_at(offset.into(), len as usize)?; + // A one-table font holding just the `name` table, to read it as the font itself would. + let mut font = Vec::with_capacity(28 + table.len()); + font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); + font.extend_from_slice(b"name"); + for v in [0, 28, len] { + font.extend_from_slice(&u32::to_be_bytes(v)); + } + font.extend_from_slice(&table); + face_names(&skrifa::FontRef::new(&font).ok()?) + }) + .collect() +} + +/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. +/// Empty on wasm. +pub fn system_font_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if cfg!(target_arch = "wasm32") { + return dirs; + } + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. + for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { + let Ok(entries) = std::fs::read_dir(parent) else { continue }; + for e in entries.flatten() { + if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { + dirs.push(e.path()); + } + } + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + dirs +} + +/// One face found in a font file: index, family, style and (variable fonts) the named instance's +/// axis settings. +type Found = (u32, String, String, Vec<([u8; 4], f32)>); + +/// Parse every face in `data` (a font file or collection); a variable font yields one face per +/// named instance (InDesign lists them as styles). +fn enumerate_faces(data: &[u8]) -> Vec { + let count = match FileRef::new(data) { + Ok(FileRef::Font(_)) => 1, + Ok(FileRef::Collection(c)) => c.len(), + Err(_) => 0, + }; + let mut out = Vec::new(); + for i in 0..count { + let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; + let Some((family, style)) = face_names(&f) else { continue }; + let axes = f.axes(); + let mut named = Vec::new(); + for ni in f.named_instances().iter() { + let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue }; + if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) { + continue; + } + let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect(); + named.push((i, style, coords)); + } + if named.is_empty() { + out.push((i, family, style, Vec::new())); + } else { + out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c))); + } + } + out +} + +/// The compiled-in last-resort face, for a database that somehow has no usable faces at all. +pub(crate) fn last_resort_face() -> Arc { + static FACE: std::sync::OnceLock> = std::sync::OnceLock::new(); + FACE.get_or_init(|| { + // The font is compiled in (`include_bytes!`), so parsing it can't depend on input; the + // `last_resort_face_parses` test proves it on every run. + #[allow(clippy::expect_used)] + let face = make_face(FontBytes::Static(epaint_default_fonts::UBUNTU_LIGHT), 0, LAST_RESORT_FAMILY.into(), "Regular".into(), Vec::new()) + .expect("the compiled-in last-resort face parses"); + Arc::new(face) + }) + .clone() +} + +fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords: Vec<([u8; 4], f32)>) -> Option { + let data: &[u8] = match &bytes { + FontBytes::Static(b) => b, + FontBytes::Owned(v) => v.as_slice(), + }; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let settings: Vec<(skrifa::Tag, f32)> = coords.iter().map(|(t, v)| (skrifa::Tag::new(t), *v)).collect(); + let location = if coords.is_empty() { Location::default() } else { f.axes().location(settings.iter().copied()) }; + let m = f.metrics(Size::unscaled(), &location); + let a = f.attributes(); + let hb = harfrust::FontRef::from_index(data, index).ok()?; + let shaper = harfrust::ShaperData::new(&hb); + let instance = (!coords.is_empty()) + .then(|| harfrust::ShaperInstance::from_variations(&hb, settings.iter().map(|(t, v)| harfrust::Variation { tag: *t, value: *v }))); + let axis = |tag: &[u8; 4]| coords.iter().find(|(t, _)| t == tag).map(|(_, v)| *v); + let weight = axis(b"wght").unwrap_or(if coords.is_empty() { a.weight.value() } else { style_weight(&style) }); + let italic = axis(b"ital") + .map(|v| v >= 0.5) + .or(axis(b"slnt").map(|v| v.abs() > 0.1)) + .unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style)); + Some(FontFace { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + family, + style, + weight, + italic, + upem: m.units_per_em.max(1) as f64, + ascent: m.ascent as f64, + descent: -(m.descent as f64), + cap_height: m.cap_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.72), + x_height: m.x_height.map(|v| v as f64).filter(|v| *v > 0.0).unwrap_or(m.ascent as f64 * 0.5), + shaper, + coords, + location, + instance, + bytes, + index, + bmp: std::sync::OnceLock::new(), + }) +} + +/// The named style part of a style with axis settings (`Bold {wght:650}` → `Bold`). +pub fn base_style(style: &str) -> &str { + style.split('{').next().unwrap_or(style).trim() +} + +fn norm(s: &str) -> String { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() +} + +/// Weight implied by a style name. +fn style_weight(style: &str) -> f32 { + let s = norm(style); + const TABLE: &[(&str, f32)] = &[ + ("extralight", 200.0), + ("ultralight", 200.0), + ("semibold", 600.0), + ("demibold", 600.0), + ("extrabold", 800.0), + ("ultrabold", 800.0), + ("hairline", 100.0), + ("thin", 100.0), + ("light", 300.0), + ("medium", 500.0), + ("bold", 700.0), + ("black", 900.0), + ("heavy", 900.0), + ]; + TABLE.iter().find(|(k, _)| s.contains(k)).map(|(_, w)| *w).unwrap_or(400.0) +} + +fn style_italic(style: &str) -> bool { + let s = norm(style); + s.contains("italic") || s.contains("oblique") || s == "it" +} + +impl FontDb { + /// A database holding the bundled fonts, then the craft-fonts Japanese faces (Mincho first, + /// so they are the fallback for Japanese text after the requested and bundled fonts; empty + /// without `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`. + pub fn with_font_dirs(font_dirs: Vec) -> Self { + let mut faces = Vec::new(); + for data in bundled() { + for (i, family, style, coords) in enumerate_faces(data) { + if let Some(f) = make_face(FontBytes::Static(data), i, family, style, coords) { + faces.push(Arc::new(f)); + } + } + } + Self { + faces: RwLock::new(faces), + outlines: Mutex::new(HashMap::new()), + #[cfg(not(target_arch = "wasm32"))] + catalog: RwLock::new(Vec::new()), + font_dirs, + #[cfg(not(target_arch = "wasm32"))] + cataloged: std::sync::OnceLock::new(), + #[cfg(not(target_arch = "wasm32"))] + sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), + } + } + + /// Enable or disable the lazy system-font fallback for characters the loaded fonts lack + /// (native only; on by default). + pub fn set_system_fallback(&self, on: bool) { + #[cfg(not(target_arch = "wasm32"))] + { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on; + } + #[cfg(target_arch = "wasm32")] + let _ = on; + } + + /// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the + /// first time a lookup by family name needs them (or ahead of time by + /// [`FontDb::scan_in_background`]). + pub fn global() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) + } + + fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { + self.faces.read().unwrap_or_else(|e| e.into_inner()) + } + + /// The system font catalog, scanned first if it hasn't been yet. + #[cfg(not(target_arch = "wasm32"))] + fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { + self.ensure_catalog(); + self.catalog.read().unwrap_or_else(|e| e.into_inner()) + } + + /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. + pub fn families(&self) -> Vec { + let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.read_catalog().iter().map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + + /// Style names available for `family` (Regular first, then by weight). + pub fn styles(&self, family: &str) -> Vec { + let mut v: Vec<(bool, f32, String)> = self + .read_faces() + .iter() + .filter(|f| f.family.eq_ignore_ascii_case(family) && !f.style.contains('{')) + .map(|f| (f.italic, f.weight, f.style.clone())) + .collect(); + #[cfg(not(target_arch = "wasm32"))] + for c in self.read_catalog().iter() { + if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { + v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + } + } + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); + v.dedup_by(|a, b| a.2 == b.2); + v.into_iter().map(|t| t.2).collect() + } + + /// Add a user font (TTF/OTF/TTC bytes). Returns the number of faces added (0 if unparseable or + /// every face was already present). + pub fn add_font(&self, bytes: Vec) -> usize { + let data = Arc::new(bytes); + let mut added = 0; + for (i, family, style, coords) in enumerate_faces(&data) { + if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, coords) { + self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); + added += 1; + } + } + added + } + + /// Catalog the installed fonts unless that has been done: the first caller scans the font + /// folders, any other waits for that scan to finish. + #[cfg(not(target_arch = "wasm32"))] + fn ensure_catalog(&self) { + self.cataloged.get_or_init(|| { + self.scan_font_dirs(); + }); + } + + /// Catalog the installed fonts on a background thread, so the first lookup by family name + /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are + /// cataloged, and on wasm. + pub fn scan_in_background(&'static self) { + #[cfg(not(target_arch = "wasm32"))] + if self.cataloged.get().is_none() { + // A failed spawn leaves the scan to the first lookup that needs it. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog()); + } + } + + /// Scan the font folders again (fonts installed or removed since), cataloging the faces + /// found (native only). Font data is loaded when a cataloged family is first resolved. + /// Returns the number of faces cataloged. + #[cfg(not(target_arch = "wasm32"))] + pub fn load_system_fonts(&self) -> usize { + // The first scan, or a rescan once it is done (never both at once). + let mut first = None; + self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); + first.unwrap_or_else(|| self.scan_font_dirs()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn scan_font_dirs(&self) -> usize { + let mut found = Vec::new(); + let mut stack = self.font_dirs.clone(); + // Each folder once, however links lead back to it. + let mut visited = std::collections::HashSet::new(); + while let Some(d) = stack.pop() { + if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { + continue; + } + let Ok(rd) = std::fs::read_dir(&d) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + continue; + } + let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { + continue; + } + for (family, style) in file_face_names(&p) { + found.push(CatalogEntry { family, style, path: p.clone() }); + } + } + } + let n = found.len(); + log::debug!("cataloged {n} system font faces"); + *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + n + } + + /// Load the files of the installed `family`. Returns whether any face was added. + #[cfg(not(target_arch = "wasm32"))] + fn load_cataloged(&self, family: &str) -> bool { + let paths: Vec = { + let cat = self.read_catalog(); + let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); + p.sort(); + p.dedup(); + p + }; + let mut any = false; + for p in paths { + if let Ok(data) = std::fs::read(&p) { + any |= self.add_font(data) > 0; + } + } + any + } + + /// Resolve a family + style to a face, falling back to the closest style of the family, then to + /// an open substitute ([`substitutes`]), then to Inter. Installed system fonts are found by name + /// whatever ran before. + pub fn face(&self, family: &str, style: &str) -> Arc { + if let Some(f) = self.try_family(family, style) { + return f; + } + for sub in substitutes(family) { + if let Some(f) = self.try_family(sub, style) { + return f; + } + } + for sub in [FALLBACK_FAMILY, "Helvetica Neue", "Arial", "Liberation Sans", "DejaVu Sans", "Segoe UI"] { + if let Some(f) = self.try_family(sub, style) { + return f; + } + } + self.find(LAST_RESORT_FAMILY, "Regular") + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.read_faces().first().cloned()) + .unwrap_or_else(last_resort_face) + } + + fn try_family(&self, family: &str, style: &str) -> Option> { + if let Some(f) = self.find(family, style) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.load_cataloged(family) + && let Some(f) = self.find(family, style) + { + return Some(f); + } + None + } + + /// Is `family` available (loaded, or installed on the system)? + pub fn has_family(&self, family: &str) -> bool { + if self.is_loaded(family) { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + if self.read_catalog().iter().any(|c| c.family.eq_ignore_ascii_case(family)) { + return true; + } + false + } + + fn is_loaded(&self, family: &str) -> bool { + self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) + } + + /// A variable font's axes: (tag, name, min, default, max), empty for static fonts. + pub fn axes(&self, family: &str, style: &str) -> Vec<(String, String, f32, f32, f32)> { + let face = self.face(family, base_style(style)); + let Some(f) = face.skrifa() else { return vec![] }; + f.axes() + .iter() + .map(|a| { + let tag = String::from_utf8_lossy(&a.tag().to_be_bytes()).to_string(); + let name = f.localized_strings(a.name_id()).english_or_first().map(|s| s.to_string()).unwrap_or_else(|| tag.clone()); + (tag, name, a.min_value(), a.default_value(), a.max_value()) + }) + .collect() + } + + /// `Style {wght:650,wdth:90}`: the named style's font at those axis values, made on first use. + fn instance(&self, family: &str, style: &str) -> Option> { + let open = style.find('{')?; + let base = self.find(family, style[..open].trim())?; + if base.skrifa().is_none_or(|f| f.axes().is_empty()) { + return Some(base); + } + let mut coords = base.coords.clone(); + for kv in style[open + 1..].trim_end_matches('}').split(',') { + let (k, v) = kv.split_once(':')?; + let k = k.trim().as_bytes(); + let v: f32 = v.trim().parse().ok()?; + if k.len() != 4 { + return None; + } + let tag = [k[0], k[1], k[2], k[3]]; + match coords.iter_mut().find(|(t, _)| *t == tag) { + Some(c) => c.1 = v, + None => coords.push((tag, v)), + } + } + let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?; + let f = Arc::new(f); + self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); + Some(f) + } + + fn find(&self, family: &str, style: &str) -> Option> { + if style.contains('{') { + { + let faces = self.read_faces(); + if let Some(f) = faces.iter().find(|f| f.family.eq_ignore_ascii_case(family) && f.style == style) { + return Some(f.clone()); + } + } + return self.instance(family, style); + } + let faces = self.read_faces(); + let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); + if cands.is_empty() { + return None; + } + let ns = norm(style); + if let Some(f) = cands.iter().find(|f| norm(&f.style) == ns) { + return Some((*f).clone()); + } + let (tw, ti) = (style_weight(style), style_italic(style)); + cands + .iter() + .min_by(|a, b| { + let sa = (a.weight - tw).abs() + if a.italic != ti { 1000.0 } else { 0.0 }; + let sb = (b.weight - tw).abs() + if b.italic != ti { 1000.0 } else { 0.0 }; + sa.total_cmp(&sb) + }) + .map(|f| (*f).clone()) + } + + /// First face (fallback family first, then load order) that covers `c`; on native, system + /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. + pub fn fallback_for(&self, c: char, exclude: u32) -> Option> { + if let Some(f) = self.loaded_fallback(c, exclude) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + None + } + + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + let faces = self.read_faces(); + let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); + order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + order.into_iter().find(|f| f.covers(c)).cloned() + } + + /// Load a system font covering `c` (preferred fallback families first, then any cataloged + /// file under 40 MB). Returns true if one was loaded. Misses are remembered. + #[cfg(not(target_arch = "wasm32"))] + fn system_fallback(&self, c: char) -> bool { + if c.is_control() || c.is_whitespace() { + return false; + } + { + let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + if !sys.enabled || sys.misses.contains(&c) { + return false; + } + } + let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); + let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); + for fam in SYSTEM_FALLBACKS { + if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { + self.load_cataloged(fam); + if covered(self) { + return true; + } + } + } + let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + paths.sort(); + paths.dedup(); + for p in paths { + if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) { + continue; + } + let Ok(data) = std::fs::read(&p) else { continue }; + let hit = + enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some())); + if hit && self.add_font(data) > 0 && covered(self) { + return true; + } + } + self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + false + } + + /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). + pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { + let key = (face.id, gid); + if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return p.clone(); + } + let mut pen = FlipPen(BezPath::new()); + if let Some(f) = face.skrifa() + && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) + { + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); + } + let p = Arc::new(pen.0); + let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner()); + if cache.len() >= OUTLINE_CACHE_MAX { + cache.clear(); + } + cache.insert(key, p.clone()); + p + } +} + +struct FlipPen(BezPath); + +impl OutlinePen for FlipPen { + fn move_to(&mut self, x: f32, y: f32) { + self.0.move_to((x as f64, -y as f64)); + } + fn line_to(&mut self, x: f32, y: f32) { + self.0.line_to((x as f64, -y as f64)); + } + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } + fn close(&mut self) { + self.0.close_path(); + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs new file mode 100644 index 0000000..1554853 --- /dev/null +++ b/crates/fonts/src/lib.rs @@ -0,0 +1,259 @@ +//! SlideCraft fonts: the font database (craft-fonts families, user and system fonts, and open +//! substitutes for the Office fonts presentations ask for), vertical metrics, glyph outlines and +//! OpenType shaping. +//! +//! Shaping here is style-agnostic: [`shape`] turns a string in one face into glyph ids, clusters +//! and advances in font units. `slidecraft-text` applies sizes, tracking, scaling and +//! justification on top. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod fontdb; + +pub use fontdb::{FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, base_style, bundled, substitutes, system_font_dirs}; +pub use harfrust::Feature; +use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; +pub use kurbo::BezPath; +use skrifa::MetadataProvider; +use skrifa::instance::Size; + +/// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts; +/// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`). +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the font is for, e.g. `"Jpan"`. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +/// The craft-fonts faces for Japanese (`"Jpan"`), in manifest order. Empty without craft-fonts. +pub fn japanese_fonts() -> impl Iterator { + CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan")) +} + +/// Japanese faces in document-fallback order: Mincho (serif) families first, matching the +/// serif default text font, then the others; Regular before other styles. +pub fn japanese_document_fonts() -> Vec<&'static CraftFont> { + let mut v: Vec<_> = japanese_fonts().collect(); + v.sort_by_key(|f| (!f.family.contains("Mincho"), f.style != "Regular")); + v +} + +/// Japanese faces in UI order: BIZ UDPGothic first (the UI face), then the others; `bold` puts +/// bold styles before regular ones. +pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { + let mut v: Vec<_> = japanese_fonts().collect(); + v.sort_by_key(|f| (f.family != "BIZ UDPGothic", (f.style == "Bold") != bold)); + v +} + +/// The default theme's font. +pub const DEFAULT_FAMILY: &str = "Inter"; + +/// One shaped glyph, in font units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShapedGlyph { + pub gid: u32, + /// Byte offset (in the shaped string) of the cluster this glyph belongs to. + pub cluster: usize, + pub x_advance: i32, + pub x_offset: i32, + pub y_offset: i32, +} + +/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`. +pub fn feature(tag: &str) -> Option { + let (on, t) = match tag.strip_prefix('-') { + Some(r) => (false, r), + None => (true, tag.strip_prefix('+').unwrap_or(tag)), + }; + let b = t.as_bytes(); + if b.len() != 4 { + return None; + } + Some(Feature::new(Tag::new(&[b[0], b[1], b[2], b[3]]), on as u32, ..)) +} + +/// A strong right-to-left character (Hebrew, Arabic, …)? +pub fn is_rtl(c: char) -> bool { + use unicode_bidi::BidiClass::{AL, R}; + matches!(unicode_bidi::bidi_class(c), R | AL) +} + +/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left). +fn direction_runs(text: &str) -> Vec<(std::ops::Range, bool)> { + let mut runs: Vec<(std::ops::Range, bool)> = Vec::new(); + let mut cur: Option = None; + let mut start = 0; + for (i, c) in text.char_indices() { + let strong = if is_rtl(c) { + Some(true) + } else if unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::L { + Some(false) + } else { + None + }; + match (cur, strong) { + (None, Some(d)) => cur = Some(d), + (Some(a), Some(d)) if a != d => { + runs.push((start..i, a)); + start = i; + cur = Some(d); + } + _ => {} + } + } + runs.push((start..text.len(), cur.unwrap_or(false))); + runs +} + +/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All +/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical +/// order: right-to-left runs are shaped right to left, then their clusters put back in text +/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually. +pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec { + if !text.chars().any(is_rtl) { + return shape_dir(face, text, features, &map, false); + } + let mut out = Vec::with_capacity(text.len()); + for (r, rtl) in direction_runs(text) { + let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl); + for x in &mut g { + x.cluster += r.start; + } + if rtl { + // Visual (clusters descending) → logical, cluster by cluster. + let mut groups: Vec> = Vec::new(); + for x in g { + match groups.last_mut() { + Some(last) if last[0].cluster == x.cluster => last.push(x), + _ => groups.push(vec![x]), + } + } + groups.sort_by_key(|grp| grp[0].cluster); + g = groups.into_iter().flatten().collect(); + } + out.extend(g); + } + out +} + +fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec { + let mut out = Vec::with_capacity(text.len()); + let shaped = face.hb().map(|hb| { + let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build(); + let mut buf = UnicodeBuffer::new(); + for (i, c) in text.char_indices() { + buf.add(map(c), i as u32); + } + buf.guess_segment_properties(); + buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + let gb = shaper.shape(buf, ShapeOptions::new().features(features)); + for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) { + out.push(ShapedGlyph { + gid: info.glyph_id, + cluster: info.cluster as usize, + x_advance: pos.x_advance, + x_offset: pos.x_offset, + y_offset: pos.y_offset, + }); + } + }); + if shaped.is_none() + && let Some(f) = face.skrifa() + { + let cmap = f.charmap(); + let gm = f.glyph_metrics(Size::unscaled(), face.location()); + for (i, c) in text.char_indices() { + let g = cmap.map(map(c)).unwrap_or_default(); + let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5); + out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 }); + } + } + out +} + +/// Glyph id of the first of `chars` the face has (0 = .notdef). +pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { + chars.iter().map(|c| face.glyph_for(*c)).find(|g| *g != 0).unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn any_face() -> std::sync::Arc { + FontDb::global().face(DEFAULT_FAMILY, "Regular") + } + + #[test] + fn last_resort_face_parses() { + let f = fontdb::last_resort_face(); + assert_eq!(f.family, fontdb::LAST_RESORT_FAMILY); + assert!(f.upem > 0.0); + } + + #[test] + fn some_face_always_resolves() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let f = db.face("No Such Font Anywhere", "Regular"); + assert!(!f.family.is_empty()); + assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); + } + + #[test] + fn office_fonts_have_substitutes() { + assert!(substitutes("Calibri").contains(&"Carlito")); + assert!(substitutes("Cambria").contains(&"Caladea")); + assert!(substitutes("Times New Roman").contains(&"Liberation Serif")); + assert!(substitutes("Aptos").contains(&"Inter")); + assert!(substitutes("zzz").is_empty()); + } + + #[test] + fn shaping_produces_clusters_and_advances() { + let face = any_face(); + let g = shape(&face, "Hello", &[], |c| c); + assert_eq!(g.len(), 5); + assert_eq!(g.iter().map(|g| g.cluster).collect::>(), vec![0, 1, 2, 3, 4]); + assert!(g.iter().all(|g| g.x_advance > 0 && g.gid != 0)); + } + + #[test] + fn mapping_keeps_clusters() { + let face = any_face(); + let g = shape(&face, "ab", &[], |c| c.to_ascii_uppercase()); + assert_eq!(g[0].gid, face.glyph_for('A')); + assert_eq!(g[1].cluster, 1); + } + + #[test] + fn outlines_and_metrics() { + let db = FontDb::global(); + let face = any_face(); + let o = db.outline(&face, face.glyph_for('O')); + assert!(!o.elements().is_empty()); + assert!(face.ascent > 0.0 && face.descent > 0.0); + assert!(feature("abc").is_none()); + assert!(feature("liga").is_some()); + } + + #[test] + fn craft_fonts_latin_when_present() { + if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { + eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); + return; + } + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.face("Inter", "Regular").family, "Inter"); + assert_eq!(db.face("Aptos", "Regular").family, "Inter"); + if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { + assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); + } + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml new file mode 100644 index 0000000..114091a --- /dev/null +++ b/crates/format/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "slidecraft-format" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft native file format (.slidecraft: zip of a JSON manifest plus media) and outline text import/export" + +[lints] +workspace = true + +[dependencies] +slidecraft-model = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +zip = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs new file mode 100644 index 0000000..e60dc2c --- /dev/null +++ b/crates/format/src/lib.rs @@ -0,0 +1,260 @@ +//! The native `.slidecraft` format: a zip holding `presentation.json` (the model, minus media +//! bytes), `media/` files with the embedded media, and `mimetype`. Readers ignore unknown +//! fields, so newer files open in older builds with what those builds understand. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use slidecraft_model::text::{Paragraph, TextBody}; +use slidecraft_model::{LayoutType, Presentation, defaults}; + +pub const MIMETYPE: &str = "application/vnd.storyteller.slidecraft+zip"; +pub const EXTENSION: &str = "slidecraft"; +/// Format version written into the manifest. +pub const VERSION: u32 = 1; +/// Largest entry we will inflate (guards against zip bombs). +const MAX_ENTRY: u64 = 2 << 30; + +#[derive(Debug, thiserror::Error)] +pub enum FormatError { + #[error("not a SlideCraft file: {0}")] + NotOurs(String), + #[error("damaged file: {0}")] + Damaged(String), + #[error("write failed: {0}")] + Write(String), +} + +pub type Result = std::result::Result; + +#[derive(serde::Serialize, serde::Deserialize)] +struct Manifest { + format: String, + version: u32, + generator: String, + presentation: Presentation, +} + +/// Serialize a presentation. +pub fn save(p: &Presentation) -> Result> { + let mut buf = Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let w = |e: zip::result::ZipError| FormatError::Write(e.to_string()); + z.start_file("mimetype", stored).map_err(w)?; + z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?; + let m = Manifest { + format: "slidecraft".into(), + version: VERSION, + generator: format!("SlideCraft {}", env!("CARGO_PKG_VERSION")), + presentation: p.clone(), + }; + let json = serde_json::to_vec(&m).map_err(|e| FormatError::Write(e.to_string()))?; + z.start_file("presentation.json", deflate).map_err(w)?; + z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; + for item in &p.media { + if item.link.is_some() && item.data.is_empty() { + continue; + } + // Already-compressed media is stored, not deflated again. + let opts = if is_compressed(&item.content_type) { stored } else { deflate }; + z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?; + z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?; + } + z.finish().map_err(w)?; + } + Ok(buf.into_inner()) +} + +fn is_compressed(ct: &str) -> bool { + ct.starts_with("image/jpeg") + || ct.starts_with("image/png") + || ct.starts_with("video/") + || ct.starts_with("audio/mpeg") + || ct.starts_with("audio/mp4") + || ct.contains("zip") +} + +/// Does this look like a `.slidecraft` file? +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK") && bytes.windows(MIMETYPE.len()).take(200).any(|w| w == MIMETYPE.as_bytes()) +} + +fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result> { + let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + if f.size() > MAX_ENTRY { + return Err(FormatError::Damaged(format!("{name} is too large"))); + } + let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize); + f.take(MAX_ENTRY).read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?; + Ok(out) +} + +/// Read a presentation. +pub fn load(bytes: &[u8]) -> Result { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?; + let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?; + let m: Manifest = serde_json::from_slice(&json).map_err(|e| FormatError::Damaged(e.to_string()))?; + if m.format != "slidecraft" { + return Err(FormatError::NotOurs(m.format)); + } + let mut p = m.presentation; + for item in &mut p.media { + if let Ok(data) = read_entry(&mut z, &format!("media/{}", item.id.0)) { + item.data = Arc::new(data); + } + } + repair(&mut p); + Ok(p) +} + +/// Make a loaded presentation safe to edit: a master exists, every slide's layout exists, +/// ids are unique, tables are rectangular. +pub fn repair(p: &mut Presentation) { + if p.masters.is_empty() { + let fresh = defaults::blank_presentation(p.slide_size, Default::default(), false); + p.masters = fresh.masters; + } + p.fix_next_id(); + let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + for i in 0..p.slides.len() { + let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none()); + let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id)); + if needs_layout || dup { + let new_id = if dup { Some(slidecraft_model::SlideId(p.alloc_id())) } else { None }; + if let Some(s) = p.slides.get_mut(i) { + let s = Arc::make_mut(s); + if needs_layout { + s.layout = first_layout; + } + if let Some(id) = new_id { + s.id = id; + } + } + } + } + if !(p.slide_size.width.is_finite() && p.slide_size.width >= 1.0 && p.slide_size.height.is_finite() && p.slide_size.height >= 1.0) { + p.slide_size = defaults::WIDE; + } + p.slide_size.width = p.slide_size.width.min(4000.0); + p.slide_size.height = p.slide_size.height.min(4000.0); +} + +/// Outline text: one slide per unindented line; tab-indented lines become body bullets at their +/// level (Insert ▸ Slides from Outline, View ▸ Outline copy). +pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize { + let Some(lay) = defaults::layout_of_kind(p, LayoutType::TitleAndContent) else { return 0 }; + let mut made = 0; + let mut cur: Option = None; + let flush = |p: &mut Presentation, s: Option, made: &mut usize| { + if let Some(s) = s { + p.slides.push(Arc::new(s)); + *made += 1; + } + }; + for raw in text.lines() { + if raw.trim().is_empty() { + continue; + } + let tabs = raw.chars().take_while(|c| *c == '\t').count(); + let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2; + let level = tabs.max(spaces); + let line = raw.trim().trim_start_matches(['-', '*', '•']).trim(); + if level == 0 { + flush(p, cur.take(), &mut made); + let mut s = defaults::new_slide(p, lay); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(line)); + } + cur = Some(s); + } else if let Some(s) = cur.as_mut() + && let Some(body) = s.shapes.get_mut(1) + { + let tb = body.text.get_or_insert_with(TextBody::default); + let mut para = Paragraph::new(line); + para.level = (level - 1).min(8) as u8; + tb.paragraphs.push(para); + } + } + flush(p, cur, &mut made); + made +} + +/// The deck as outline text (titles unindented, body paragraphs tab-indented by level). +pub fn slides_to_outline(p: &Presentation) -> String { + let mut out = String::new(); + for s in &p.slides { + out.push_str(&s.title()); + out.push('\n'); + for sh in &s.shapes { + if sh.ph_type().is_some_and(|k| k.is_title()) { + continue; + } + if let Some(t) = &sh.text { + for para in &t.paragraphs { + if para.is_empty() { + continue; + } + for _ in 0..=para.level { + out.push('\t'); + } + out.push_str(¶.text().replace('\u{b}', " ")); + out.push('\n'); + } + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_with_media() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]); + let bytes = save(&p).unwrap(); + assert!(sniff(&bytes)); + let q = load(&bytes).unwrap(); + assert_eq!(q.slides.len(), p.slides.len()); + assert_eq!(q.media(id).map(|m| m.data.as_slice()), Some(&[1u8, 2, 3, 4][..])); + assert_eq!(serde_json::to_value(&q.slides).unwrap(), serde_json::to_value(&p.slides).unwrap()); + } + + #[test] + fn garbage_is_an_error_not_a_panic() { + assert!(load(b"").is_err()); + assert!(load(b"PK\x03\x04garbage").is_err()); + assert!(load(&[0u8; 1000]).is_err()); + let mut b = save(&Presentation::default()).unwrap(); + let n = b.len(); + b.truncate(n / 2); + assert!(load(&b).is_err()); + } + + #[test] + fn repair_fixes_missing_layout_and_size() { + let mut p = Presentation::default(); + Arc::make_mut(&mut p.slides[0]).layout = slidecraft_model::LayoutId(99999); + p.slide_size.width = f64::NAN; + repair(&mut p); + assert!(p.validate().is_empty()); + assert_eq!(p.slide_size.width, 960.0); + } + + #[test] + fn outline_roundtrip() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + let n = outline_to_slides(&mut p, "Intro\n\tPoint one\n\t\tSub point\nSecond\n\tMore"); + assert_eq!(n, 2); + let o = slides_to_outline(&p); + assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}"); + } +} diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml new file mode 100644 index 0000000..e261479 --- /dev/null +++ b/crates/geom/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "slidecraft-geom" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft geometry: units (EMU/pt), transforms, preset shape generators, adjust handles, hit testing" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +serde = { workspace = true } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs new file mode 100644 index 0000000..eca310b --- /dev/null +++ b/crates/geom/src/lib.rs @@ -0,0 +1,266 @@ +//! SlideCraft geometry: units, shape transforms and preset shape generators. +//! +//! The document model keeps positions in points (1/72 inch) as `f64`. Office Open XML stores EMU +//! (English Metric Units, 12 700 per point); [`emu_to_pt`] and [`pt_to_emu`] convert losslessly for +//! integer EMU values, so files round-trip exactly. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod preset; + +pub use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Size, Vec2}; +use serde::{Deserialize, Serialize}; + +/// EMU per point. +pub const EMU_PER_PT: f64 = 12_700.0; +/// EMU per inch. +pub const EMU_PER_IN: f64 = 914_400.0; +/// EMU per centimetre. +pub const EMU_PER_CM: f64 = 360_000.0; + +pub fn emu_to_pt(emu: i64) -> f64 { + emu as f64 / EMU_PER_PT +} + +/// Points to EMU, rounded. Non-finite input becomes 0; huge values saturate. +pub fn pt_to_emu(pt: f64) -> i64 { + if !pt.is_finite() { + return 0; + } + let v = (pt * EMU_PER_PT).round(); + if v >= i64::MAX as f64 { + i64::MAX + } else if v <= i64::MIN as f64 { + i64::MIN + } else { + v as i64 + } +} + +/// Display units for measurements (inches on US English systems, centimetres elsewhere). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Unit { + #[default] + Inches, + Centimeters, + Points, + Pixels, +} + +impl Unit { + pub fn points_per_unit(self) -> f64 { + match self { + Unit::Inches => 72.0, + Unit::Centimeters => 72.0 / 2.54, + Unit::Points => 1.0, + Unit::Pixels => 0.75, + } + } + pub fn suffix(self) -> &'static str { + match self { + Unit::Inches => "\"", + Unit::Centimeters => " cm", + Unit::Points => " pt", + Unit::Pixels => " px", + } + } + /// `2.08"` style text for a length in points. + pub fn format(self, pt: f64) -> String { + let v = pt / self.points_per_unit(); + let s = format!("{v:.2}"); + format!("{s}{}", self.suffix()) + } + /// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`. + pub fn parse(self, text: &str) -> Option { + let t = text.trim().to_ascii_lowercase(); + let (num, unit) = split_number(&t)?; + let ppu = match unit.trim() { + "" => self.points_per_unit(), + "\"" | "in" | "inch" | "inches" => 72.0, + "cm" => 72.0 / 2.54, + "mm" => 72.0 / 25.4, + "pt" | "pts" => 1.0, + "px" => 0.75, + _ => return None, + }; + let v = num * ppu; + v.is_finite().then_some(v) + } +} + +fn split_number(t: &str) -> Option<(f64, &str)> { + let end = t.char_indices().find(|(_, c)| !(c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+')).map(|(i, _)| i).unwrap_or(t.len()); + let n: f64 = t.get(..end)?.parse().ok()?; + Some((n, t.get(end..).unwrap_or(""))) +} + +/// A shape's placement on its slide (or in its group's child space): offset, extent, rotation +/// (degrees clockwise) and flips. Rotation and flips are about the centre. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Xfrm { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, + #[serde(skip_serializing_if = "is_zero")] + pub rot: f64, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip_h: bool, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip_v: bool, +} + +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl Xfrm { + pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + Xfrm { x, y, w, h, ..Default::default() } + } + pub fn rect(&self) -> Rect { + Rect::new(self.x, self.y, self.x + self.w, self.y + self.h) + } + pub fn center(&self) -> Point { + Point::new(self.x + self.w / 2.0, self.y + self.h / 2.0) + } + /// Local shape space (0..w, 0..h) → parent space, applying flips and rotation about the centre. + pub fn affine(&self) -> Affine { + let c = Vec2::new(self.w / 2.0, self.h / 2.0); + let sx = if self.flip_h { -1.0 } else { 1.0 }; + let sy = if self.flip_v { -1.0 } else { 1.0 }; + Affine::translate(Vec2::new(self.x, self.y) + c) + * Affine::rotate(self.rot.to_radians()) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate(-c) + } + /// Axis-aligned bounds of the rotated box in parent space. + pub fn bounds(&self) -> Rect { + let a = self.affine(); + let pts = [Point::new(0.0, 0.0), Point::new(self.w, 0.0), Point::new(self.w, self.h), Point::new(0.0, self.h)].map(|p| a * p); + let mut r = Rect::from_points(pts[0], pts[1]); + r = r.union_pt(pts[2]).union_pt(pts[3]); + r + } + /// True when no dimension is NaN/inf. + pub fn is_finite(&self) -> bool { + [self.x, self.y, self.w, self.h, self.rot].iter().all(|v| v.is_finite()) + } + /// Rotation normalised to 0..360. + pub fn rot_norm(&self) -> f64 { + let r = self.rot % 360.0; + if r < 0.0 { r + 360.0 } else { r } + } +} + +/// Maps a group's child coordinate space (`ch_off`, `ch_ext`) onto the group's own box. +pub fn group_child_affine(group: &Xfrm, ch: Rect) -> Affine { + let sx = if ch.width().abs() > 1e-9 { group.w / ch.width() } else { 1.0 }; + let sy = if ch.height().abs() > 1e-9 { group.h / ch.height() } else { 1.0 }; + group.affine() * Affine::scale_non_uniform(sx, sy) * Affine::translate(-ch.origin().to_vec2()) +} + +/// Distance from `p` to the segment `a`–`b`. +pub fn dist_to_segment(p: Point, a: Point, b: Point) -> f64 { + let ab = b - a; + let len2 = ab.hypot2(); + if len2 < 1e-12 { + return (p - a).hypot(); + } + let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0); + (p - (a + ab * t)).hypot() +} + +/// Is `p` within `tol` of the outline of `path` (flattened)? +pub fn near_path(path: &BezPath, p: Point, tol: f64) -> bool { + let mut hit = false; + let mut start = Point::ZERO; + let mut last = Point::ZERO; + kurbo::flatten(path.iter(), 0.25, |el| { + if hit { + return; + } + match el { + PathEl::MoveTo(q) => { + start = q; + last = q; + } + PathEl::LineTo(q) => { + if dist_to_segment(p, last, q) <= tol { + hit = true; + } + last = q; + } + PathEl::ClosePath => { + if dist_to_segment(p, last, start) <= tol { + hit = true; + } + last = start; + } + _ => {} + } + }); + hit +} + +/// Snap `v` to the nearest multiple of `step` (step <= 0 returns `v`). +pub fn snap(v: f64, step: f64) -> f64 { + if step > 0.0 && step.is_finite() { (v / step).round() * step } else { v } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn emu_roundtrip_is_exact() { + for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] { + assert_eq!(pt_to_emu(emu_to_pt(emu)), emu); + } + assert_eq!(pt_to_emu(f64::NAN), 0); + assert_eq!(pt_to_emu(f64::INFINITY), 0); + } + + #[test] + fn unit_parse_and_format() { + assert_eq!(Unit::Inches.parse("2"), Some(144.0)); + assert_eq!(Unit::Inches.parse("2\""), Some(144.0)); + assert!((Unit::Inches.parse("2.54 cm").unwrap() - 72.0).abs() < 1e-9); + assert_eq!(Unit::Inches.parse("36pt"), Some(36.0)); + assert_eq!(Unit::Inches.parse("abc"), None); + assert_eq!(Unit::Inches.parse(""), None); + assert_eq!(Unit::Inches.format(150.0), "2.08\""); + } + + #[test] + fn xfrm_affine_rotation_about_center() { + let x = Xfrm { x: 10.0, y: 20.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() }; + let c = x.affine() * Point::new(50.0, 25.0); + assert!((c.x - 60.0).abs() < 1e-9 && (c.y - 45.0).abs() < 1e-9); + let b = x.bounds(); + assert!((b.width() - 50.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9); + } + + #[test] + fn flips_mirror_local_space() { + let x = Xfrm { x: 0.0, y: 0.0, w: 100.0, h: 50.0, flip_h: true, ..Default::default() }; + let p = x.affine() * Point::new(0.0, 0.0); + assert!((p.x - 100.0).abs() < 1e-9 && p.y.abs() < 1e-9); + } + + #[test] + fn group_child_mapping() { + let g = Xfrm::new(100.0, 100.0, 200.0, 100.0); + let a = group_child_affine(&g, Rect::new(0.0, 0.0, 100.0, 50.0)); + let p = a * Point::new(100.0, 50.0); + assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9); + } + + #[test] + fn segment_distance() { + assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9); + assert!((dist_to_segment(Point::new(0.0, 3.0), Point::new(0.0, 0.0), Point::new(0.0, 0.0)) - 3.0).abs() < 1e-9); + } +} diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs new file mode 100644 index 0000000..09f9869 --- /dev/null +++ b/crates/geom/src/preset.rs @@ -0,0 +1,1926 @@ +//! Preset shape geometry. +//! +//! Each preset is identified by its Office Open XML name (`rect`, `roundRect`, `rightArrow`…) so +//! files round-trip, but every outline here is our own construction in Rust: we re-derive the shapes +//! from what they look like and from the meaning of their adjust values, not from any reference +//! geometry table. Adjust values use the file convention: ratios in 1/100 000 of the shorter side (or +//! of the width/height), angles in 1/60 000 degree. + +use kurbo::{Arc, BezPath, Point, Rect, Shape as _, Vec2}; +use serde::{Deserialize, Serialize}; + +/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FillMode { + Norm, + None, + Lighten, + LightenLess, + Darken, + DarkenLess, +} + +#[derive(Clone, Debug)] +pub struct SubPath { + pub path: BezPath, + pub fill: FillMode, + pub stroke: bool, + /// Even-odd fill (holes in donuts and frames). + pub even_odd: bool, +} + +/// A draggable adjust handle (the yellow dot). `pos = origin + dir * value`; dragging projects +/// the pointer onto `dir`. +#[derive(Clone, Debug, PartialEq)] +pub struct Handle { + pub index: usize, + pub pos: Point, + pub origin: Point, + pub dir: Vec2, + pub min: f64, + pub max: f64, +} + +impl Handle { + fn new(index: usize, origin: Point, dir: Vec2, value: f64, min: f64, max: f64) -> Self { + Handle { index, pos: origin + dir * value, origin, dir, min, max } + } + /// The adjust value for a pointer at `p` (shape-local space). + pub fn value_at(&self, p: Point) -> f64 { + let l2 = self.dir.hypot2(); + if l2 < 1e-18 { + return self.min; + } + let v = (p - self.origin).dot(self.dir) / l2; + if !v.is_finite() { + return self.min; + } + v.clamp(self.min.min(self.max), self.max.max(self.min)).round() + } +} + +#[derive(Clone, Debug)] +pub struct Geometry { + pub paths: Vec, + /// Where text goes, in shape-local space. + pub text_rect: Rect, + pub handles: Vec, + /// Connector glue points, shape-local. + pub sites: Vec, +} + +impl Geometry { + /// The union outline used for hit testing and selection (all filled or stroked sub-paths). + pub fn outline(&self) -> BezPath { + let mut p = BezPath::new(); + for s in &self.paths { + p.extend(s.path.iter()); + } + p + } + /// Whether nothing is filled (lines, connectors, arcs, brackets). + pub fn is_open(&self) -> bool { + self.paths.iter().all(|s| s.fill == FillMode::None) + } +} + +/// Gallery category, as in the Shapes menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Category { + Lines, + Rectangles, + BasicShapes, + BlockArrows, + EquationShapes, + Flowchart, + StarsAndBanners, + Callouts, + ActionButtons, +} + +impl Category { + pub const ALL: [Category; 9] = [ + Category::Lines, + Category::Rectangles, + Category::BasicShapes, + Category::BlockArrows, + Category::EquationShapes, + Category::Flowchart, + Category::StarsAndBanners, + Category::Callouts, + Category::ActionButtons, + ]; + pub fn label(self) -> &'static str { + match self { + Category::Lines => "Lines", + Category::Rectangles => "Rectangles", + Category::BasicShapes => "Basic Shapes", + Category::BlockArrows => "Block Arrows", + Category::EquationShapes => "Equation Shapes", + Category::Flowchart => "Flowchart", + Category::StarsAndBanners => "Stars and Banners", + Category::Callouts => "Callouts", + Category::ActionButtons => "Action Buttons", + } + } +} + +pub struct PresetInfo { + pub name: &'static str, + pub label: &'static str, + pub category: Category, + /// Default adjust values. + pub defaults: &'static [f64], +} + +macro_rules! p { + ($n:literal, $l:literal, $c:ident, [$($d:expr),*]) => { + PresetInfo { name: $n, label: $l, category: Category::$c, defaults: &[$($d as f64),*] } + }; +} + +/// Every preset we generate, in gallery order. +pub static CATALOG: &[PresetInfo] = &[ + p!("line", "Line", Lines, []), + p!("straightConnector1", "Arrow", Lines, []), + p!("bentConnector3", "Connector: Elbow", Lines, [50000]), + p!("curvedConnector3", "Connector: Curved", Lines, [50000]), + p!("bentConnector2", "Connector: Elbow (one bend)", Lines, []), + p!("rect", "Rectangle", Rectangles, []), + p!("roundRect", "Rectangle: Rounded Corners", Rectangles, [16667]), + p!("snip1Rect", "Rectangle: Single Corner Snipped", Rectangles, [16667]), + p!("snip2SameRect", "Rectangle: Top Corners Snipped", Rectangles, [16667, 0]), + p!("snip2DiagRect", "Rectangle: Diagonal Corners Snipped", Rectangles, [0, 16667]), + p!("snipRoundRect", "Rectangle: Top Corners One Rounded and One Snipped", Rectangles, [16667, 16667]), + p!("round1Rect", "Rectangle: Single Corner Rounded", Rectangles, [16667]), + p!("round2SameRect", "Rectangle: Top Corners Rounded", Rectangles, [16667, 0]), + p!("round2DiagRect", "Rectangle: Diagonal Corners Rounded", Rectangles, [16667, 0]), + p!("textBox", "Text Box", BasicShapes, []), + p!("ellipse", "Oval", BasicShapes, []), + p!("triangle", "Isosceles Triangle", BasicShapes, [50000]), + p!("rtTriangle", "Right Triangle", BasicShapes, []), + p!("parallelogram", "Parallelogram", BasicShapes, [25000]), + p!("trapezoid", "Trapezoid", BasicShapes, [25000]), + p!("diamond", "Diamond", BasicShapes, []), + p!("pentagon", "Regular Pentagon", BasicShapes, []), + p!("hexagon", "Hexagon", BasicShapes, [25000]), + p!("heptagon", "Heptagon", BasicShapes, []), + p!("octagon", "Octagon", BasicShapes, [29289]), + p!("decagon", "Decagon", BasicShapes, []), + p!("dodecagon", "Dodecagon", BasicShapes, []), + p!("pie", "Partial Circle", BasicShapes, [0, 16200000]), + p!("chord", "Chord", BasicShapes, [2700000, 16200000]), + p!("teardrop", "Teardrop", BasicShapes, [100000]), + p!("frame", "Frame", BasicShapes, [12500]), + p!("halfFrame", "Half Frame", BasicShapes, [33333, 33333]), + p!("corner", "L-Shape", BasicShapes, [50000, 50000]), + p!("diagStripe", "Diagonal Stripe", BasicShapes, [50000]), + p!("plus", "Cross", BasicShapes, [25000]), + p!("plaque", "Plaque", BasicShapes, [16667]), + p!("can", "Cylinder", BasicShapes, [25000]), + p!("cube", "Cube", BasicShapes, [25000]), + p!("bevel", "Bevel", BasicShapes, [12500]), + p!("donut", "Donut", BasicShapes, [25000]), + p!("noSmoking", "\"Not Allowed\" Symbol", BasicShapes, [18750]), + p!("blockArc", "Block Arc", BasicShapes, [10800000, 0, 25000]), + p!("foldedCorner", "Folded Corner", BasicShapes, [16667]), + p!("smileyFace", "Smiley Face", BasicShapes, [4653]), + p!("heart", "Heart", BasicShapes, []), + p!("lightningBolt", "Lightning Bolt", BasicShapes, []), + p!("sun", "Sun", BasicShapes, [25000]), + p!("moon", "Moon", BasicShapes, [50000]), + p!("cloud", "Cloud", BasicShapes, []), + p!("arc", "Arc", BasicShapes, [16200000, 0]), + p!("bracketPair", "Double Bracket", BasicShapes, [16667]), + p!("bracePair", "Double Brace", BasicShapes, [8333]), + p!("leftBracket", "Left Bracket", BasicShapes, [8333]), + p!("rightBracket", "Right Bracket", BasicShapes, [8333]), + p!("leftBrace", "Left Brace", BasicShapes, [8333, 50000]), + p!("rightBrace", "Right Brace", BasicShapes, [8333, 50000]), + p!("rightArrow", "Arrow: Right", BlockArrows, [50000, 50000]), + p!("leftArrow", "Arrow: Left", BlockArrows, [50000, 50000]), + p!("upArrow", "Arrow: Up", BlockArrows, [50000, 50000]), + p!("downArrow", "Arrow: Down", BlockArrows, [50000, 50000]), + p!("leftRightArrow", "Arrow: Left-Right", BlockArrows, [50000, 50000]), + p!("upDownArrow", "Arrow: Up-Down", BlockArrows, [50000, 50000]), + p!("quadArrow", "Arrow: Quad", BlockArrows, [22500, 22500, 22500]), + p!("leftRightUpArrow", "Arrow: Left-Right-Up", BlockArrows, [25000, 25000, 25000]), + p!("bentArrow", "Arrow: Bent", BlockArrows, [25000, 25000, 25000, 43750]), + p!("uturnArrow", "Arrow: U-Turn", BlockArrows, [25000, 25000, 25000, 43750, 75000]), + p!("leftUpArrow", "Arrow: Left-Up", BlockArrows, [25000, 25000, 25000]), + p!("bentUpArrow", "Arrow: Bent-Up", BlockArrows, [25000, 25000, 25000]), + p!("stripedRightArrow", "Arrow: Striped Right", BlockArrows, [50000, 50000]), + p!("notchedRightArrow", "Arrow: Notched Right", BlockArrows, [50000, 50000]), + p!("homePlate", "Arrow: Pentagon", BlockArrows, [50000]), + p!("chevron", "Arrow: Chevron", BlockArrows, [50000]), + p!("rightArrowCallout", "Callout: Right Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("downArrowCallout", "Callout: Down Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("leftArrowCallout", "Callout: Left Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("upArrowCallout", "Callout: Up Arrow", BlockArrows, [25000, 25000, 25000, 64977]), + p!("mathPlus", "Plus Sign", EquationShapes, [23520]), + p!("mathMinus", "Minus Sign", EquationShapes, [23520]), + p!("mathMultiply", "Multiplication Sign", EquationShapes, [23520]), + p!("mathDivide", "Division Sign", EquationShapes, [23520, 5880, 11760]), + p!("mathEqual", "Equals", EquationShapes, [23520, 11760]), + p!("mathNotEqual", "Not Equal", EquationShapes, [23520, 6600000, 11760]), + p!("flowChartProcess", "Flowchart: Process", Flowchart, []), + p!("flowChartAlternateProcess", "Flowchart: Alternate Process", Flowchart, []), + p!("flowChartDecision", "Flowchart: Decision", Flowchart, []), + p!("flowChartInputOutput", "Flowchart: Data", Flowchart, []), + p!("flowChartPredefinedProcess", "Flowchart: Predefined Process", Flowchart, []), + p!("flowChartInternalStorage", "Flowchart: Internal Storage", Flowchart, []), + p!("flowChartDocument", "Flowchart: Document", Flowchart, []), + p!("flowChartMultidocument", "Flowchart: Multidocument", Flowchart, []), + p!("flowChartTerminator", "Flowchart: Terminator", Flowchart, []), + p!("flowChartPreparation", "Flowchart: Preparation", Flowchart, []), + p!("flowChartManualInput", "Flowchart: Manual Input", Flowchart, []), + p!("flowChartManualOperation", "Flowchart: Manual Operation", Flowchart, []), + p!("flowChartConnector", "Flowchart: Connector", Flowchart, []), + p!("flowChartOffpageConnector", "Flowchart: Off-page Connector", Flowchart, []), + p!("flowChartPunchedCard", "Flowchart: Card", Flowchart, []), + p!("flowChartPunchedTape", "Flowchart: Punched Tape", Flowchart, []), + p!("flowChartSummingJunction", "Flowchart: Summing Junction", Flowchart, []), + p!("flowChartOr", "Flowchart: Or", Flowchart, []), + p!("flowChartCollate", "Flowchart: Collate", Flowchart, []), + p!("flowChartSort", "Flowchart: Sort", Flowchart, []), + p!("flowChartExtract", "Flowchart: Extract", Flowchart, []), + p!("flowChartMerge", "Flowchart: Merge", Flowchart, []), + p!("flowChartOnlineStorage", "Flowchart: Stored Data", Flowchart, []), + p!("flowChartDelay", "Flowchart: Delay", Flowchart, []), + p!("flowChartMagneticTape", "Flowchart: Sequential Access Storage", Flowchart, []), + p!("flowChartMagneticDisk", "Flowchart: Magnetic Disk", Flowchart, []), + p!("flowChartMagneticDrum", "Flowchart: Direct Access Storage", Flowchart, []), + p!("flowChartDisplay", "Flowchart: Display", Flowchart, []), + p!("irregularSeal1", "Explosion: 8 Points", StarsAndBanners, []), + p!("irregularSeal2", "Explosion: 14 Points", StarsAndBanners, []), + p!("star4", "Star: 4 Points", StarsAndBanners, [12500]), + p!("star5", "Star: 5 Points", StarsAndBanners, [19098]), + p!("star6", "Star: 6 Points", StarsAndBanners, [28868]), + p!("star7", "Star: 7 Points", StarsAndBanners, [34601]), + p!("star8", "Star: 8 Points", StarsAndBanners, [37500]), + p!("star10", "Star: 10 Points", StarsAndBanners, [42533]), + p!("star12", "Star: 12 Points", StarsAndBanners, [37500]), + p!("star16", "Star: 16 Points", StarsAndBanners, [37500]), + p!("star24", "Star: 24 Points", StarsAndBanners, [37500]), + p!("star32", "Star: 32 Points", StarsAndBanners, [37500]), + p!("ribbon2", "Ribbon: Tilted Up", StarsAndBanners, [16667, 50000]), + p!("ribbon", "Ribbon: Tilted Down", StarsAndBanners, [16667, 50000]), + p!("verticalScroll", "Scroll: Vertical", StarsAndBanners, [12500]), + p!("horizontalScroll", "Scroll: Horizontal", StarsAndBanners, [12500]), + p!("wave", "Wave", StarsAndBanners, [12500, 0]), + p!("doubleWave", "Double Wave", StarsAndBanners, [6250, 0]), + p!("wedgeRectCallout", "Speech Bubble: Rectangle", Callouts, [-20833, 62500]), + p!("wedgeRoundRectCallout", "Speech Bubble: Rectangle with Corners Rounded", Callouts, [-20833, 62500, 16667]), + p!("wedgeEllipseCallout", "Speech Bubble: Oval", Callouts, [-20833, 62500]), + p!("cloudCallout", "Thought Bubble: Cloud", Callouts, [-20833, 62500]), + p!("borderCallout1", "Callout: Line", Callouts, [18750, -8333, 112500, -38333]), + p!("actionButtonBackPrevious", "Action Button: Go Back or Previous", ActionButtons, []), + p!("actionButtonForwardNext", "Action Button: Go Forward or Next", ActionButtons, []), + p!("actionButtonBeginning", "Action Button: Go to Beginning", ActionButtons, []), + p!("actionButtonEnd", "Action Button: Go to End", ActionButtons, []), + p!("actionButtonHome", "Action Button: Go Home", ActionButtons, []), + p!("actionButtonInformation", "Action Button: Get Information", ActionButtons, []), + p!("actionButtonReturn", "Action Button: Go Back", ActionButtons, []), + p!("actionButtonMovie", "Action Button: Video", ActionButtons, []), + p!("actionButtonDocument", "Action Button: Document", ActionButtons, []), + p!("actionButtonSound", "Action Button: Sound", ActionButtons, []), + p!("actionButtonHelp", "Action Button: Help", ActionButtons, []), + p!("actionButtonBlank", "Action Button: Custom", ActionButtons, []), +]; + +pub fn info(name: &str) -> Option<&'static PresetInfo> { + CATALOG.iter().find(|p| p.name == name) +} + +pub fn is_line_like(name: &str) -> bool { + matches!(name, "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "curvedConnector3" | "arc") + || name.starts_with("bentConnector") + || name.starts_with("curvedConnector") +} + +// ---------- path builder ---------- + +struct Pb { + p: BezPath, + cur: Point, +} + +impl Pb { + fn new() -> Self { + Pb { p: BezPath::new(), cur: Point::ZERO } + } + fn m(&mut self, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.move_to(self.cur); + self + } + fn l(&mut self, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.line_to(self.cur); + self + } + fn c(&mut self, x1: f64, y1: f64, x2: f64, y2: f64, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.curve_to(Point::new(x1, y1), Point::new(x2, y2), self.cur); + self + } + fn q(&mut self, x1: f64, y1: f64, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.quad_to(Point::new(x1, y1), self.cur); + self + } + /// Elliptic arc continuing from the current point, which lies on the ellipse at parametric + /// angle `st` (degrees); sweeps `sw` degrees (positive = clockwise on screen). + fn arc(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { + let (s, e) = (st.to_radians(), (st + sw).to_radians()); + let c = Point::new(self.cur.x - wr * s.cos(), self.cur.y - hr * s.sin()); + let a = Arc::new(c, Vec2::new(wr.abs(), hr.abs()), s, e - s, 0.0); + a.to_cubic_beziers(0.05, |p1, p2, p| { + self.p.curve_to(p1, p2, p); + }); + self.cur = Point::new(c.x + wr * e.cos(), c.y + hr * e.sin()); + self + } + fn z(&mut self) -> &mut Self { + self.p.close_path(); + self + } + fn poly(&mut self, pts: &[(f64, f64)]) -> &mut Self { + for (i, (x, y)) in pts.iter().enumerate() { + if i == 0 { + self.m(*x, *y); + } else { + self.l(*x, *y); + } + } + self.z() + } + fn ellipse(&mut self, cx: f64, cy: f64, rx: f64, ry: f64) -> &mut Self { + self.m(cx + rx, cy); + self.arc(rx, ry, 0.0, 360.0); + self.z() + } + fn take(&mut self) -> BezPath { + std::mem::take(&mut self.p) + } +} + +fn fill(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: true, even_odd: false } +} +fn fill_eo(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: true, even_odd: true } +} +fn shade(p: BezPath, m: FillMode) -> SubPath { + SubPath { path: p, fill: m, stroke: true, even_odd: false } +} +fn stroke_only(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::None, stroke: true, even_odd: false } +} +fn fill_only(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: false, even_odd: false } +} + +/// Parametric angle on an rx×ry ellipse for a visual angle `deg` (file angles are visual). +fn vis(deg: f64, rx: f64, ry: f64) -> f64 { + let t = deg.to_radians(); + (rx * t.sin()).atan2(ry * t.cos()).to_degrees() +} + +fn polygon_regular(n: usize, w: f64, h: f64, phase: f64) -> Vec<(f64, f64)> { + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let a = (phase + 360.0 * i as f64 / n as f64).to_radians(); + (a.cos(), a.sin()) + }) + .collect(); + normalize(&pts, w, h) +} + +/// Scale `pts` so their bounding box fills 0..w × 0..h. +fn normalize(pts: &[(f64, f64)], w: f64, h: f64) -> Vec<(f64, f64)> { + let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + for (x, y) in pts { + x0 = x0.min(*x); + y0 = y0.min(*y); + x1 = x1.max(*x); + y1 = y1.max(*y); + } + let sx = if x1 - x0 > 1e-12 { w / (x1 - x0) } else { 0.0 }; + let sy = if y1 - y0 > 1e-12 { h / (y1 - y0) } else { 0.0 }; + pts.iter().map(|(x, y)| ((x - x0) * sx, (y - y0) * sy)).collect() +} + +fn star(n: usize, w: f64, h: f64, inner: f64, normalized: bool) -> Vec<(f64, f64)> { + let mut pts = Vec::with_capacity(n * 2); + for i in 0..n * 2 { + let a = (-90.0 + 180.0 * i as f64 / n as f64).to_radians(); + let r = if i % 2 == 0 { 1.0 } else { inner }; + pts.push((r * a.cos(), r * a.sin())); + } + if normalized { normalize(&pts, w, h) } else { pts.iter().map(|(x, y)| (w / 2.0 + x * w / 2.0, h / 2.0 + y * h / 2.0)).collect() } +} + +#[derive(Clone, Copy)] +enum Corner { + Square, + Round(f64), + Snip(f64), +} + +/// A rectangle with each corner (TL, TR, BR, BL) square, rounded or snipped. +fn corner_rect(w: f64, h: f64, c: [Corner; 4]) -> BezPath { + let mut b = Pb::new(); + let size = |k: Corner| match k { + Corner::Square => 0.0, + Corner::Round(r) | Corner::Snip(r) => r.max(0.0), + }; + let s0 = size(c[0]); + b.m(s0, 0.0); + // top edge → TR + let s1 = size(c[1]); + b.l(w - s1, 0.0); + match c[1] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 270.0, 90.0); + } + Corner::Snip(_) => { + b.l(w, s1); + } + _ => {} + } + let s2 = size(c[2]); + b.l(w, h - s2); + match c[2] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 0.0, 90.0); + } + Corner::Snip(_) => { + b.l(w - s2, h); + } + _ => {} + } + let s3 = size(c[3]); + b.l(s3, h); + match c[3] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 90.0, 90.0); + } + Corner::Snip(_) => { + b.l(0.0, h - s3); + } + _ => {} + } + b.l(0.0, s0); + match c[0] { + Corner::Round(r) if r > 0.0 => { + b.arc(r, r, 180.0, 90.0); + } + Corner::Snip(_) => { + b.l(s0, 0.0); + } + _ => {} + } + b.z(); + b.take() +} + +fn mid_sites(w: f64, h: f64) -> Vec { + vec![Point::new(w / 2.0, 0.0), Point::new(0.0, h / 2.0), Point::new(w / 2.0, h), Point::new(w, h / 2.0)] +} + +/// Orientation for single-axis arrows built along +x. +#[derive(Clone, Copy)] +enum Dir { + Right, + Left, + Up, + Down, +} + +/// Map canonical (u along the arrow's length L, v across thickness T) to shape space. +fn orient(d: Dir, w: f64, h: f64, pts: &[(f64, f64)]) -> Vec<(f64, f64)> { + pts.iter() + .map(|&(u, v)| match d { + Dir::Right => (u, v), + Dir::Left => (w - u, v), + Dir::Up => (v, h - u), + Dir::Down => (v, u), + }) + .collect() +} + +fn len_thick(d: Dir, w: f64, h: f64) -> (f64, f64) { + match d { + Dir::Right | Dir::Left => (w, h), + Dir::Up | Dir::Down => (h, w), + } +} + +fn arrow(d: Dir, w: f64, h: f64, a1: f64, a2: f64) -> Geometry { + let (l, t) = len_thick(d, w, h); + let ss = l.min(t); + let a1 = a1.clamp(0.0, 100000.0) / 100000.0; + let dx = (ss * a2.clamp(0.0, 100000.0 * l / ss.max(1e-9)) / 100000.0).min(l); + let y1 = t / 2.0 - t * a1 / 2.0; + let y2 = t / 2.0 + t * a1 / 2.0; + let pts = [(0.0, y1), (l - dx, y1), (l - dx, 0.0), (l, t / 2.0), (l - dx, t), (l - dx, y2), (0.0, y2)]; + let pts = orient(d, w, h, &pts); + let tr = orient(d, w, h, &[(0.0, y1), (l - dx + dx * (y1 / (t / 2.0).max(1e-9)), y2)]); + let mut text = Rect::from_points(tr[0], tr[1]); + text = text.abs(); + let mut g = Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: text, handles: vec![], sites: mid_sites(w, h) }; + // handle 0: shaft thickness on the head base; handle 1: head length. + let o = orient(d, w, h, &[(l - dx, t / 2.0), (l, t / 2.0)]); + let (o0, o1) = (Point::new(o[0].0, o[0].1), Point::new(o[1].0, o[1].1)); + let across = match d { + Dir::Right | Dir::Left => Vec2::new(0.0, -t / 2.0 / 100000.0), + Dir::Up | Dir::Down => Vec2::new(-t / 2.0 / 100000.0, 0.0), + }; + let along = (o0 - o1) * (ss / 100000.0 / dx.max(1e-9)); + g.handles.push(Handle::new(0, o0, across, a1 * 100000.0, 0.0, 100000.0)); + if dx > 1e-9 { + g.handles.push(Handle::new(1, o1, along, a2, 0.0, 100000.0 * l / ss.max(1e-9))); + } + g +} + +fn double_arrow(vertical: bool, w: f64, h: f64, a1: f64, a2: f64) -> Geometry { + let (l, t) = if vertical { (h, w) } else { (w, h) }; + let ss = l.min(t); + let a1 = a1.clamp(0.0, 100000.0) / 100000.0; + let dx = (ss * a2.max(0.0) / 100000.0).min(l / 2.0); + let y1 = t / 2.0 - t * a1 / 2.0; + let y2 = t / 2.0 + t * a1 / 2.0; + let pts = [(0.0, t / 2.0), (dx, 0.0), (dx, y1), (l - dx, y1), (l - dx, 0.0), (l, t / 2.0), (l - dx, t), (l - dx, y2), (dx, y2), (dx, t)]; + let pts = orient(if vertical { Dir::Down } else { Dir::Right }, w, h, &pts); + let r = if vertical { Rect::new(y1, dx, y2, h - dx) } else { Rect::new(dx, y1, w - dx, y2) }; + Geometry { paths: vec![fill(Pb::new().poly(&pts).take())], text_rect: r, handles: vec![], sites: mid_sites(w, h) } +} + +fn simple(path: BezPath, text: Rect, w: f64, h: f64) -> Geometry { + Geometry { paths: vec![fill(path)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +/// Builds the geometry for preset `name` at size `w`×`h` with adjust values `adj` (missing +/// values use the preset's defaults). Unknown names return `None`; the caller falls back to `rect`. +pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option { + if !(w.is_finite() && h.is_finite()) { + return None; + } + let w = w.max(0.0); + let h = h.max(0.0); + let defaults = info(name).map(|i| i.defaults).unwrap_or(&[]); + let a = |i: usize| -> f64 { + let v = adj.get(i).copied().or_else(|| defaults.get(i).copied()).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + }; + let ss = w.min(h); + let full = Rect::new(0.0, 0.0, w, h); + let mut b = Pb::new(); + let g = match name { + "rect" | "textBox" | "flowChartProcess" => simple(corner_rect(w, h, [Corner::Square; 4]), full, w, h), + "actionButtonBlank" + | "actionButtonBackPrevious" + | "actionButtonForwardNext" + | "actionButtonBeginning" + | "actionButtonEnd" + | "actionButtonHome" + | "actionButtonInformation" + | "actionButtonReturn" + | "actionButtonMovie" + | "actionButtonDocument" + | "actionButtonSound" + | "actionButtonHelp" => action_button(name, w, h), + "roundRect" => { + let v = a(0).clamp(0.0, 50000.0); + let r = ss * v / 100000.0; + let mut g = simple(corner_rect(w, h, [Corner::Round(r); 4]), full.inset(-r * 0.29289), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "flowChartAlternateProcess" => simple(corner_rect(w, h, [Corner::Round(ss / 6.0); 4]), full.inset(-ss / 6.0 * 0.29289), w, h), + "snip1Rect" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let mut g = simple( + corner_rect(w, h, [Corner::Square, Corner::Snip(d), Corner::Square, Corner::Square]), + Rect::new(0.0, d / 2.0, w - d / 2.0, h), + w, + h, + ); + g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "snip2SameRect" => { + let (d1, d2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(corner_rect(w, h, [Corner::Snip(d1), Corner::Snip(d1), Corner::Snip(d2), Corner::Snip(d2)]), full.inset(-d1.max(d2) / 2.0), w, h) + } + "snip2DiagRect" => { + let (d1, d2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(corner_rect(w, h, [Corner::Snip(d1), Corner::Snip(d2), Corner::Snip(d1), Corner::Snip(d2)]), full.inset(-d1.max(d2) / 2.0), w, h) + } + "snipRoundRect" => { + let (r, d) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(corner_rect(w, h, [Corner::Round(r), Corner::Snip(d), Corner::Square, Corner::Square]), full.inset(-r.max(d) / 2.0), w, h) + } + "round1Rect" => { + let r = ss * a(0).clamp(0.0, 50000.0) / 1e5; + simple(corner_rect(w, h, [Corner::Square, Corner::Round(r), Corner::Square, Corner::Square]), full.inset(-r * 0.29289), w, h) + } + "round2SameRect" => { + let (r1, r2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple( + corner_rect(w, h, [Corner::Round(r1), Corner::Round(r1), Corner::Round(r2), Corner::Round(r2)]), + full.inset(-r1.max(r2) * 0.29289), + w, + h, + ) + } + "round2DiagRect" => { + let (r1, r2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple( + corner_rect(w, h, [Corner::Round(r1), Corner::Round(r2), Corner::Round(r1), Corner::Round(r2)]), + full.inset(-r1.max(r2) * 0.29289), + w, + h, + ) + } + "ellipse" | "flowChartConnector" => { + let mut g = + simple(b.ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h); + g.sites = ellipse_sites(w, h); + g + } + "triangle" | "flowChartExtract" => { + let v = if name == "triangle" { a(0).clamp(0.0, 100000.0) } else { 50000.0 }; + let ax = w * v / 100000.0; + let mut g = simple(b.poly(&[(0.0, h), (ax, 0.0), (w, h)]).take(), Rect::new(ax / 2.0, h / 2.0, (ax + w) / 2.0, h), w, h); + g.sites = vec![ + Point::new(ax, 0.0), + Point::new(ax / 2.0, h / 2.0), + Point::new(0.0, h), + Point::new(w / 2.0, h), + Point::new(w, h), + Point::new((ax + w) / 2.0, h / 2.0), + ]; + if name == "triangle" { + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(w / 100000.0, 0.0), v, 0.0, 100000.0)); + } + g + } + "flowChartMerge" => simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w / 2.0, h)]).take(), Rect::new(w / 4.0, 0.0, w * 0.75, h / 2.0), w, h), + "rtTriangle" => { + simple(b.poly(&[(0.0, 0.0), (0.0, h), (w, h)]).take(), Rect::new(w / 12.0, h * 7.0 / 12.0, w * 7.0 / 12.0, h * 11.0 / 12.0), w, h) + } + "parallelogram" | "flowChartInputOutput" => { + let v = if name == "parallelogram" { a(0).clamp(0.0, 100000.0 * w / ss.max(1e-9)) } else { 20000.0 * w / ss.max(1e-9) }; + let off = ss * v / 100000.0; + let mut g = simple( + b.poly(&[(0.0, h), (off, 0.0), (w, 0.0), (w - off, h)]).take(), + Rect::new(off / 2.0 + off * 0.2, h * 0.08, w - off / 2.0 - off * 0.2, h * 0.92), + w, + h, + ); + if name == "parallelogram" { + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); + } + g + } + "trapezoid" => { + let v = a(0).clamp(0.0, 50000.0 * w / ss.max(1e-9)); + let off = ss * v / 100000.0; + let mut g = + simple(b.poly(&[(0.0, h), (off, 0.0), (w - off, 0.0), (w, h)]).take(), Rect::new(off * 0.66, h * 0.2, w - off * 0.66, h), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0 * w / ss.max(1e-9))); + g + } + "flowChartManualOperation" => { + simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w * 0.8, h), (w * 0.2, h)]).take(), Rect::new(w * 0.2, 0.0, w * 0.8, h), w, h) + } + "diamond" | "flowChartDecision" => simple( + b.poly(&[(w / 2.0, 0.0), (w, h / 2.0), (w / 2.0, h), (0.0, h / 2.0)]).take(), + Rect::new(w / 4.0, h / 4.0, w * 0.75, h * 0.75), + w, + h, + ), + "flowChartSort" => { + let d = b.poly(&[(w / 2.0, 0.0), (w, h / 2.0), (w / 2.0, h), (0.0, h / 2.0)]).take(); + let line = Pb::new().m(0.0, h / 2.0).l(w, h / 2.0).take(); + Geometry { + paths: vec![fill(d), stroke_only(line)], + text_rect: Rect::new(w / 4.0, h / 4.0, w * 0.75, h * 0.75), + handles: vec![], + sites: mid_sites(w, h), + } + } + "pentagon" => simple(b.poly(&polygon_regular(5, w, h, -90.0)).take(), Rect::new(w * 0.2, h * 0.27, w * 0.8, h * 0.9), w, h), + "heptagon" => simple(b.poly(&polygon_regular(7, w, h, -90.0)).take(), Rect::new(w * 0.16, h * 0.2, w * 0.84, h * 0.86), w, h), + "decagon" => simple(b.poly(&polygon_regular(10, w, h, 0.0)).take(), Rect::new(w * 0.1, h * 0.17, w * 0.9, h * 0.83), w, h), + "dodecagon" => simple(b.poly(&polygon_regular(12, w, h, 15.0)).take(), Rect::new(w * 0.13, h * 0.13, w * 0.87, h * 0.87), w, h), + "hexagon" | "flowChartPreparation" => { + let v = if name == "hexagon" { a(0).clamp(0.0, 50000.0 * w / ss.max(1e-9)) } else { 20000.0 * w / ss.max(1e-9) }; + let off = ss * v / 100000.0; + let mut g = simple( + b.poly(&[(0.0, h / 2.0), (off, 0.0), (w - off, 0.0), (w, h / 2.0), (w - off, h), (off, h)]).take(), + Rect::new(off * 0.6, h * 0.14, w - off * 0.6, h * 0.86), + w, + h, + ); + if name == "hexagon" { + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0 * w / ss.max(1e-9))); + } + g + } + "octagon" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let mut g = simple( + b.poly(&[(d, 0.0), (w - d, 0.0), (w, d), (w, h - d), (w - d, h), (d, h), (0.0, h - d), (0.0, d)]).take(), + full.inset(-d / 2.0), + w, + h, + ); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "plus" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let mut g = simple( + b.poly(&[ + (d, 0.0), + (w - d, 0.0), + (w - d, d), + (w, d), + (w, h - d), + (w - d, h - d), + (w - d, h), + (d, h), + (d, h - d), + (0.0, h - d), + (0.0, d), + (d, d), + ]) + .take(), + Rect::new(0.0, d, w, h - d), + w, + h, + ); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "frame" => { + let v = a(0).clamp(0.0, 50000.0); + let t = ss * v / 100000.0; + b.poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]); + b.poly(&[(t, t), (w - t, t), (w - t, h - t), (t, h - t)]); + let mut g = Geometry { paths: vec![fill_eo(b.take())], text_rect: full.inset(-t), handles: vec![], sites: mid_sites(w, h) }; + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "halfFrame" => { + let (t1, t2) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5); + simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w - t1, t1), (t2, t1), (t2, h - t2), (0.0, h)]).take(), full, w, h) + } + "corner" => { + let (t1, t2) = (ss * a(0).clamp(0.0, 100000.0) / 1e5, ss * a(1).clamp(0.0, 100000.0) / 1e5); + simple(b.poly(&[(0.0, 0.0), (t2, 0.0), (t2, h - t1), (w, h - t1), (w, h), (0.0, h)]).take(), Rect::new(0.0, h - t1, w, h), w, h) + } + "diagStripe" => { + let k = a(0).clamp(0.0, 100000.0) / 1e5; + simple(b.poly(&[(0.0, h * k), (w * k, 0.0), (w, 0.0), (0.0, h)]).take(), Rect::new(0.0, 0.0, w / 2.0, h / 2.0), w, h) + } + "plaque" => { + let v = a(0).clamp(0.0, 50000.0); + let r = ss * v / 100000.0; + b.m(0.0, r) + .arc(r, r, 90.0, -90.0) + .l(w - r, 0.0) + .arc(r, r, 180.0, -90.0) + .l(w, h - r) + .arc(r, r, 270.0, -90.0) + .l(r, h) + .arc(r, r, 0.0, -90.0) + .z(); + let mut g = simple(b.take(), full.inset(-r * 0.7), w, h); + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "can" | "flowChartMagneticDisk" => { + let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 33333.0 * h / ss.max(1e-9) / 2.0 }; + let eh = ss * v / 100000.0; + let r = eh / 2.0; + let body = Pb::new().m(0.0, r).l(0.0, h - r).arc(w / 2.0, r, 180.0, -180.0).l(w, r).arc(w / 2.0, r, 0.0, -180.0).z().take(); + let top = Pb::new().ellipse(w / 2.0, r, w / 2.0, r).take(); + let mut g = Geometry { + paths: vec![fill(body), shade(top, if name == "can" { FillMode::Lighten } else { FillMode::Norm })], + text_rect: Rect::new(0.0, eh, w, h - r), + handles: vec![], + sites: mid_sites(w, h), + }; + if name == "can" { + g.handles.push(Handle::new(0, Point::new(w / 2.0, 0.0), Vec2::new(0.0, ss / 100000.0), v, 0.0, 50000.0 * h / ss.max(1e-9))); + } + g + } + "flowChartMagneticDrum" => { + let r = w / 6.0 / 2.0; + let body = Pb::new().m(r, 0.0).l(w - r, 0.0).arc(r, h / 2.0, 270.0, 180.0).l(r, h).arc(r, h / 2.0, 90.0, 180.0).z().take(); + let side = Pb::new().m(w - r, 0.0).arc(r, h / 2.0, 270.0, -180.0).take(); + Geometry { + paths: vec![fill(body), stroke_only(side)], + text_rect: Rect::new(r, 0.0, w - 2.0 * r, h), + handles: vec![], + sites: mid_sites(w, h), + } + } + "cube" => { + let v = a(0).clamp(0.0, 100000.0); + let d = ss * v / 100000.0; + let front = Pb::new().poly(&[(0.0, d), (w - d, d), (w - d, h), (0.0, h)]).take(); + let top = Pb::new().poly(&[(0.0, d), (d, 0.0), (w, 0.0), (w - d, d)]).take(); + let side = Pb::new().poly(&[(w - d, d), (w, 0.0), (w, h - d), (w - d, h)]).take(); + let mut g = Geometry { + paths: vec![fill(front), shade(top, FillMode::LightenLess), shade(side, FillMode::DarkenLess)], + text_rect: Rect::new(0.0, d, w - d, h), + handles: vec![], + sites: mid_sites(w, h), + }; + g.handles.push(Handle::new(0, Point::new(0.0, 0.0), Vec2::new(0.0, ss / 100000.0), v, 0.0, 100000.0)); + g + } + "bevel" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let inner = Pb::new().poly(&[(d, d), (w - d, d), (w - d, h - d), (d, h - d)]).take(); + let top = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w - d, d), (d, d)]).take(); + let left = Pb::new().poly(&[(0.0, 0.0), (d, d), (d, h - d), (0.0, h)]).take(); + let right = Pb::new().poly(&[(w, 0.0), (w, h), (w - d, h - d), (w - d, d)]).take(); + let bottom = Pb::new().poly(&[(0.0, h), (d, h - d), (w - d, h - d), (w, h)]).take(); + let mut g = Geometry { + paths: vec![ + fill(inner), + shade(top, FillMode::LightenLess), + shade(left, FillMode::Lighten), + shade(right, FillMode::DarkenLess), + shade(bottom, FillMode::Darken), + ], + text_rect: full.inset(-d), + handles: vec![], + sites: mid_sites(w, h), + }; + g.handles.push(Handle::new(0, Point::ZERO, Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "donut" => { + let v = a(0).clamp(0.0, 50000.0); + let t = ss * v / 100000.0; + b.ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0); + b.ellipse(w / 2.0, h / 2.0, (w / 2.0 - t).max(0.0), (h / 2.0 - t).max(0.0)); + let mut g = Geometry { + paths: vec![fill_eo(b.take())], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![], + sites: ellipse_sites(w, h), + }; + g.handles.push(Handle::new(0, Point::new(0.0, h / 2.0), Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)); + g + } + "noSmoking" => { + let v = a(0).clamp(0.0, 50000.0); + let t = ss * v / 100000.0; + let (rx, ry) = ((w / 2.0 - t).max(0.0), (h / 2.0 - t).max(0.0)); + b.ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0); + b.ellipse(w / 2.0, h / 2.0, rx, ry); + let ring = b.take(); + let th = (h).atan2(w); + let dir = Vec2::new(th.cos(), th.sin()); + let r = if rx > 0.0 && ry > 0.0 { rx * ry / ((ry * th.cos()).powi(2) + (rx * th.sin()).powi(2)).sqrt() } else { 0.0 }; + let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0); + let c = Point::new(w / 2.0, h / 2.0); + let p = [c - dir * r - perp, c + dir * r - perp, c + dir * r + perp, c - dir * r + perp]; + let bar = Pb::new().poly(&p.map(|q| (q.x, q.y))).take(); + Geometry { + paths: vec![fill_eo(ring), fill(bar)], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![Handle::new(0, Point::new(0.0, h / 2.0), Vec2::new(ss / 100000.0, 0.0), v, 0.0, 50000.0)], + sites: ellipse_sites(w, h), + } + } + "blockArc" => { + let st = a(0) / 60000.0; + let en = a(1) / 60000.0; + let t = ss * a(2).clamp(0.0, 50000.0) / 100000.0; + let mut sw = en - st; + if sw <= 0.0 { + sw += 360.0; + } + let (rx, ry) = (w / 2.0, h / 2.0); + let (irx, iry) = ((rx - t).max(0.0), (ry - t).max(0.0)); + let s0 = vis(st, rx, ry); + let e0 = vis(st + sw, rx, ry); + let mut sweep = e0 - s0; + if sweep <= 0.0 { + sweep += 360.0; + } + let si = vis(st + sw, irx, iry); + let ei = vis(st, irx, iry); + let mut isweep = ei - si; + if isweep >= 0.0 { + isweep -= 360.0; + } + let p0 = Point::new(w / 2.0 + rx * s0.to_radians().cos(), h / 2.0 + ry * s0.to_radians().sin()); + b.m(p0.x, p0.y).arc(rx, ry, s0, sweep); + let pi = Point::new(w / 2.0 + irx * si.to_radians().cos(), h / 2.0 + iry * si.to_radians().sin()); + b.l(pi.x, pi.y).arc(irx, iry, si, isweep).z(); + simple(b.take(), full, w, h) + } + "pie" | "chord" | "arc" => { + let (rx, ry) = (w / 2.0, h / 2.0); + let s = vis(a(0) / 60000.0, rx, ry); + let e = vis(a(1) / 60000.0, rx, ry); + let mut sw = e - s; + if sw <= 0.0 { + sw += 360.0; + } + let p0 = Point::new(rx + rx * s.to_radians().cos(), ry + ry * s.to_radians().sin()); + b.m(p0.x, p0.y).arc(rx, ry, s, sw); + let text = Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355); + match name { + "pie" => { + b.l(rx, ry).z(); + simple(b.take(), text, w, h) + } + "chord" => { + b.z(); + simple(b.take(), text, w, h) + } + _ => { + let open = b.take(); + let mut f = BezPath::from_vec(open.elements().to_vec()); + f.line_to(Point::new(rx, ry)); + f.close_path(); + Geometry { paths: vec![fill_only(f), stroke_only(open)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } + } + } + } + "teardrop" => { + let k = a(0).clamp(0.0, 200000.0) / 100000.0; + let tip = (w / 2.0 + w / 2.0 * k, h / 2.0 - h / 2.0 * k); + b.m(0.0, h / 2.0).arc(w / 2.0, h / 2.0, 180.0, 90.0); + b.q(w / 2.0 + w / 4.0 * k, 0.0, tip.0, tip.1).q(w, h / 2.0 - h / 4.0 * k, w, h / 2.0); + b.arc(w / 2.0, h / 2.0, 0.0, 180.0).z(); + simple(b.take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h) + } + "foldedCorner" => { + let v = a(0).clamp(0.0, 50000.0); + let d = ss * v / 100000.0; + let body = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h - d), (w - d, h), (0.0, h)]).take(); + let fold = Pb::new().m(w - d, h).l(w - d + d * 0.2, h - d + d * 0.2).l(w, h - d).z().take(); + Geometry { + paths: vec![fill(body), shade(fold, FillMode::DarkenLess)], + text_rect: Rect::new(0.0, 0.0, w, h - d), + handles: vec![], + sites: mid_sites(w, h), + } + } + "smileyFace" => { + let k = a(0).clamp(-4653.0, 4653.0) / 100000.0; + let face = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(); + let mut eyes = Pb::new(); + eyes.ellipse(w * 0.35, h * 0.37, w * 0.05, h * 0.05); + eyes.ellipse(w * 0.65, h * 0.37, w * 0.05, h * 0.05); + let ys = h * (0.72 - k); + let smile = Pb::new().m(w * 0.22, ys).q(w / 2.0, ys + h * k * 4.0 + h * 0.0, w * 0.78, ys).take(); + Geometry { + paths: vec![fill(face), shade(eyes.take(), FillMode::DarkenLess), stroke_only(smile)], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![], + sites: ellipse_sites(w, h), + } + } + "heart" => { + b.m(w / 2.0, h * 0.25); + b.c(w * 0.62, h * -0.02, w, h * 0.0, w, h * 0.32); + b.c(w, h * 0.6, w * 0.62, h * 0.78, w / 2.0, h); + b.c(w * 0.38, h * 0.78, 0.0, h * 0.6, 0.0, h * 0.32); + b.c(0.0, h * 0.0, w * 0.38, h * -0.02, w / 2.0, h * 0.25); + b.z(); + simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h) + } + "lightningBolt" => { + let pts = normalize(&[(0.36, 0.0), (0.68, 0.0), (0.53, 0.34), (0.79, 0.34), (0.24, 1.0), (0.39, 0.53), (0.15, 0.53)], w, h); + simple(b.poly(&pts).take(), Rect::new(w * 0.3, h * 0.35, w * 0.65, h * 0.6), w, h) + } + "sun" => { + let k = a(0).clamp(12500.0, 46875.0) / 100000.0; + let (cx, cy) = (w / 2.0, h / 2.0); + let cr = 0.5 - k; + let core = Pb::new().ellipse(cx, cy, w * cr * 0.72, h * cr * 0.72).take(); + let mut rays = Pb::new(); + for i in 0..8 { + let ang = (i as f64 * 45.0).to_radians(); + let half = 9f64.to_radians(); + let rb = cr * 0.72 + 0.06; + let tip = (cx + w * 0.5 * ang.cos(), cy + h * 0.5 * ang.sin()); + let b1 = (cx + w * rb * (ang - half).cos(), cy + h * rb * (ang - half).sin()); + let b2 = (cx + w * rb * (ang + half).cos(), cy + h * rb * (ang + half).sin()); + rays.poly(&[tip, b1, b2]); + } + Geometry { + paths: vec![fill(rays.take()), fill(core)], + text_rect: Rect::new(w * 0.3, h * 0.3, w * 0.7, h * 0.7), + handles: vec![], + sites: mid_sites(w, h), + } + } + "moon" => { + let k = a(0).clamp(0.0, 87500.0) / 100000.0; + let g0 = w * k; + b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0).arc((w - g0).max(0.0), h / 2.0, 90.0, 180.0).z(); + simple(b.take(), Rect::new(w * 0.1, h * 0.25, w * (1.0 - k).max(0.2) * 0.8, h * 0.75), w, h) + } + "cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)), + "bracketPair" => { + let r = ss * a(0).clamp(0.0, 50000.0) / 1e5; + let mut l = Pb::new(); + l.m(r, h).arc(r, r, 90.0, 90.0).l(0.0, r).arc(r, r, 180.0, 90.0); + l.m(w - r, 0.0).arc(r, r, 270.0, 90.0).l(w, h - r).arc(r, r, 0.0, 90.0); + let f = corner_rect(w, h, [Corner::Round(r); 4]); + Geometry { + paths: vec![fill_only(f), stroke_only(l.take())], + text_rect: full.inset(-r * 0.29289), + handles: vec![], + sites: mid_sites(w, h), + } + } + "bracePair" => { + let r = ss * a(0).clamp(0.0, 25000.0) / 1e5; + let mut l = Pb::new(); + l.m(2.0 * r, h).arc(r, r, 90.0, 90.0).l(r, h / 2.0 + r).arc(r, r, 0.0, -90.0).arc(r, r, 90.0, -90.0).l(r, r).arc(r, r, 180.0, 90.0); + l.m(w - 2.0 * r, 0.0) + .arc(r, r, 270.0, 90.0) + .l(w - r, h / 2.0 - r) + .arc(r, r, 180.0, -90.0) + .arc(r, r, 270.0, -90.0) + .l(w - r, h - r) + .arc(r, r, 0.0, 90.0); + let f = corner_rect(w, h, [Corner::Round(r); 4]); + Geometry { paths: vec![fill_only(f), stroke_only(l.take())], text_rect: full.inset(-r * 1.2), handles: vec![], sites: mid_sites(w, h) } + } + "leftBracket" | "rightBracket" => { + let r = (ss * a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) / 1e5).min(h / 2.0); + let mut l = Pb::new(); + l.m(w, h).arc(w, r, 90.0, 90.0).l(0.0, r).arc(w, r, 180.0, 90.0); + let mut path = l.take(); + if name == "rightBracket" { + path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0])); + } + Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "leftBrace" | "rightBrace" => { + let r = (ss * a(0).clamp(0.0, 25000.0 * h / ss.max(1e-9)) / 1e5).min(h / 4.0); + let my = h * a(1).clamp(0.0, 100000.0) / 1e5; + let hw = w / 2.0; + let mut l = Pb::new(); + l.m(w, h).arc(hw, r, 90.0, 90.0).l(hw, my + r).arc(hw, r, 0.0, -90.0).arc(hw, r, 90.0, -90.0).l(hw, r).arc(hw, r, 180.0, 90.0); + let mut path = l.take(); + if name == "rightBrace" { + path.apply_affine(kurbo::Affine::new([-1.0, 0.0, 0.0, 1.0, w, 0.0])); + } + Geometry { paths: vec![stroke_only(path)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "rightArrow" => arrow(Dir::Right, w, h, a(0), a(1)), + "leftArrow" => arrow(Dir::Left, w, h, a(0), a(1)), + "upArrow" => arrow(Dir::Up, w, h, a(0), a(1)), + "downArrow" => arrow(Dir::Down, w, h, a(0), a(1)), + "leftRightArrow" => double_arrow(false, w, h, a(0), a(1)), + "upDownArrow" => double_arrow(true, w, h, a(0), a(1)), + "notchedRightArrow" | "stripedRightArrow" => { + let mut g = arrow(Dir::Right, w, h, a(0), a(1)); + let k = a(0).clamp(0.0, 100000.0) / 1e5; + let (y1, y2) = (h / 2.0 - h * k / 2.0, h / 2.0 + h * k / 2.0); + let dx = (ss * a(1).max(0.0) / 1e5).min(w); + if name == "notchedRightArrow" { + let n = dx * (k / 2.0) * 0.9; + let pts = [(0.0, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (0.0, y2), (n, h / 2.0)]; + g.paths = vec![fill(Pb::new().poly(&pts).take())]; + } else { + let s = ss / 32.0; + let start = 5.0 * s; + let pts = [(start, y1), (w - dx, y1), (w - dx, 0.0), (w, h / 2.0), (w - dx, h), (w - dx, y2), (start, y2)]; + let mut stripes = Pb::new(); + stripes.poly(&[(0.0, y1), (s, y1), (s, y2), (0.0, y2)]); + stripes.poly(&[(2.0 * s, y1), (4.0 * s, y1), (4.0 * s, y2), (2.0 * s, y2)]); + g.paths = vec![fill(stripes.take()), fill(Pb::new().poly(&pts).take())]; + } + g + } + "homePlate" | "chevron" => { + let v = a(0).clamp(0.0, 100000.0 * w / ss.max(1e-9)); + let d = (ss * v / 1e5).min(w); + let pts: Vec<(f64, f64)> = if name == "homePlate" { + vec![(0.0, 0.0), (w - d, 0.0), (w, h / 2.0), (w - d, h), (0.0, h)] + } else { + vec![(0.0, 0.0), (w - d, 0.0), (w, h / 2.0), (w - d, h), (0.0, h), (d, h / 2.0)] + }; + let tx = if name == "homePlate" { 0.0 } else { d }; + let mut g = simple(b.poly(&pts).take(), Rect::new(tx, 0.0, w - d / 2.0, h), w, h); + g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); + g + } + "quadArrow" => { + let (t, hw, hl) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5, ss * a(2).clamp(0.0, 50000.0) / 1e5); + let (cx, cy) = (w / 2.0, h / 2.0); + let (t2, hw2) = (t / 2.0, hw); + let pts = [ + (0.0, cy), + (hl, cy - hw2), + (hl, cy - t2), + (cx - t2, cy - t2), + (cx - t2, hl), + (cx - hw2, hl), + (cx, 0.0), + (cx + hw2, hl), + (cx + t2, hl), + (cx + t2, cy - t2), + (w - hl, cy - t2), + (w - hl, cy - hw2), + (w, cy), + (w - hl, cy + hw2), + (w - hl, cy + t2), + (cx + t2, cy + t2), + (cx + t2, h - hl), + (cx + hw2, h - hl), + (cx, h), + (cx - hw2, h - hl), + (cx - t2, h - hl), + (cx - t2, cy + t2), + (hl, cy + t2), + (hl, cy + hw2), + ]; + simple(b.poly(&pts).take(), Rect::new(cx - t2, cy - t2, cx + t2, cy + t2), w, h) + } + "leftRightUpArrow" => { + let (t, hw, hl) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5, ss * a(2).clamp(0.0, 50000.0) / 1e5); + let cx = w / 2.0; + let by = h - hw; // horizontal shaft centre + let pts = [ + (0.0, by), + (hl, by - hw), + (hl, by - t / 2.0), + (cx - t / 2.0, by - t / 2.0), + (cx - t / 2.0, hl), + (cx - hw, hl), + (cx, 0.0), + (cx + hw, hl), + (cx + t / 2.0, hl), + (cx + t / 2.0, by - t / 2.0), + (w - hl, by - t / 2.0), + (w - hl, by - hw), + (w, by), + (w - hl, h), + (w - hl, by + t / 2.0), + (hl, by + t / 2.0), + (hl, h), + ]; + simple(b.poly(&pts).take(), Rect::new(hl, by - t / 2.0, w - hl, by + t / 2.0), w, h) + } + "leftUpArrow" | "bentUpArrow" => { + let (t, hw, hl) = (ss * a(0).clamp(0.0, 50000.0) / 1e5, ss * a(1).clamp(0.0, 50000.0) / 1e5, ss * a(2).clamp(0.0, 50000.0) / 1e5); + let pts: Vec<(f64, f64)> = if name == "leftUpArrow" { + let cx = w - hw; + let cy = h - hw; + vec![ + (0.0, cy), + (hl, cy - hw), + (hl, cy - t / 2.0), + (cx - t / 2.0, cy - t / 2.0), + (cx - t / 2.0, hl), + (cx - hw, hl), + (cx, 0.0), + (w, hl), + (cx + t / 2.0, hl), + (cx + t / 2.0, cy + t / 2.0), + (hl, cy + t / 2.0), + (hl, h), + ] + } else { + let cx = w - hw; + vec![ + (0.0, h - t), + (cx - t / 2.0, h - t), + (cx - t / 2.0, hl), + (cx - hw, hl), + (cx, 0.0), + (w, hl), + (cx + t / 2.0, hl), + (cx + t / 2.0, h), + (0.0, h), + ] + }; + simple(b.poly(&pts).take(), full, w, h) + } + "bentArrow" => { + let (t, hw, hl, r) = ( + ss * a(0).clamp(0.0, 50000.0) / 1e5, + ss * a(1).clamp(0.0, 50000.0) / 1e5, + ss * a(2).clamp(0.0, 50000.0) / 1e5, + ss * a(3).clamp(0.0, 100000.0) / 1e5, + ); + let cy = hw; // head centre line + let ro = r.max(t); + let ri = (ro - t).max(0.0); + b.m(0.0, h) + .l(0.0, cy - t / 2.0 + ro) + .arc(ro, ro, 180.0, 90.0) + .l(w - hl, cy - t / 2.0) + .l(w - hl, 0.0) + .l(w, cy) + .l(w - hl, 2.0 * hw) + .l(w - hl, cy + t / 2.0); + b.l(t + ri, cy + t / 2.0); + if ri > 0.0 { + b.arc(ri, ri, 270.0, -90.0); + } + b.l(t, h).z(); + simple(b.take(), full, w, h) + } + "uturnArrow" => { + let (t, hw, hl, r) = ( + ss * a(0).clamp(0.0, 25000.0) / 1e5, + ss * a(1).clamp(0.0, 25000.0) / 1e5, + ss * a(2).clamp(0.0, 25000.0) / 1e5, + ss * a(3).clamp(0.0, 100000.0) / 1e5, + ); + let hy = (h * a(4).clamp(0.0, 100000.0) / 1e5).max(hl + t); + let ax = w - hw; // arrow shaft centre x + let ro = r.max(t).min((ax + t / 2.0) / 2.0); + let ri = (ro - t).max(0.0); + b.m(0.0, h).l(0.0, ro).arc(ro, ro, 180.0, 90.0).l(ax + t / 2.0 - ro, 0.0).arc(ro, ro, 270.0, 90.0); + b.l(ax + t / 2.0, hy - hl).l(w, hy - hl).l(ax, hy).l(ax - hw, hy - hl).l(ax - t / 2.0, hy - hl).l(ax - t / 2.0, ro); + if ri > 0.0 { + b.arc(ri, ri, 0.0, -90.0); + } + b.l(t + ri, t); + if ri > 0.0 { + b.arc(ri, ri, 270.0, -90.0); + } + b.l(t, h).z(); + simple(b.take(), full, w, h) + } + "rightArrowCallout" | "leftArrowCallout" | "upArrowCallout" | "downArrowCallout" => { + let d = match name { + "rightArrowCallout" => Dir::Right, + "leftArrowCallout" => Dir::Left, + "upArrowCallout" => Dir::Up, + _ => Dir::Down, + }; + let (l, t) = len_thick(d, w, h); + let ss2 = l.min(t); + let shaft = ss2 * a(0).clamp(0.0, 100000.0) / 1e5; + let head = ss2 * a(1).clamp(0.0, 100000.0) / 1e5; + let hl = ss2 * a(2).clamp(0.0, 100000.0) / 1e5; + let boxl = (l * a(3).clamp(0.0, 100000.0) / 1e5).min(l - hl); + let c = t / 2.0; + let pts = [ + (0.0, 0.0), + (boxl, 0.0), + (boxl, c - shaft / 2.0), + (l - hl, c - shaft / 2.0), + (l - hl, c - head), + (l, c), + (l - hl, c + head), + (l - hl, c + shaft / 2.0), + (boxl, c + shaft / 2.0), + (boxl, t), + (0.0, t), + ]; + let pts = orient(d, w, h, &pts); + let tr = orient(d, w, h, &[(0.0, 0.0), (boxl, t)]); + simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h) + } + "mathPlus" | "mathMinus" | "mathEqual" | "mathDivide" | "mathMultiply" | "mathNotEqual" => math(name, w, h, a(0), a(1), a(2)), + "flowChartPredefinedProcess" => { + let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); + let mut l = Pb::new(); + l.m(w / 8.0, 0.0).l(w / 8.0, h).m(w * 7.0 / 8.0, 0.0).l(w * 7.0 / 8.0, h); + Geometry { + paths: vec![fill(r), stroke_only(l.take())], + text_rect: Rect::new(w / 8.0, 0.0, w * 7.0 / 8.0, h), + handles: vec![], + sites: mid_sites(w, h), + } + } + "flowChartInternalStorage" => { + let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); + let mut l = Pb::new(); + l.m(w / 8.0, 0.0).l(w / 8.0, h).m(0.0, h / 8.0).l(w, h / 8.0); + Geometry { + paths: vec![fill(r), stroke_only(l.take())], + text_rect: Rect::new(w / 8.0, h / 8.0, w, h), + handles: vec![], + sites: mid_sites(w, h), + } + } + "flowChartDocument" => { + b.m(0.0, 0.0).l(w, 0.0).l(w, h * 0.8).c(w * 0.7, h * 0.62, w * 0.35, h * 1.08, 0.0, h * 0.88).z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h * 0.78), w, h) + } + "flowChartMultidocument" => { + let (dx, dy) = (w * 0.1, h * 0.1); + let mut back = Pb::new(); + back.poly(&[(2.0 * dx, 0.0), (w, 0.0), (w, h * 0.7), (w - dx * 0.5, h * 0.7), (w - dx * 0.5, dy * 0.5), (2.0 * dx, dy * 0.5)]); + back.poly(&[(dx, dy * 0.5), (w - dx * 0.5, dy * 0.5), (w - dx * 0.5, h * 0.78), (w - dx, h * 0.78), (w - dx, dy), (dx, dy)]); + let front = Pb::new().m(0.0, dy).l(w - dx, dy).l(w - dx, h * 0.84).c(w * 0.62, h * 0.7, w * 0.32, h * 1.08, 0.0, h * 0.9).z().take(); + Geometry { + paths: vec![fill(back.take()), fill(front)], + text_rect: Rect::new(0.0, dy, w - dx, h * 0.8), + handles: vec![], + sites: mid_sites(w, h), + } + } + "flowChartTerminator" => { + let rx = (w * 0.16).min(w / 2.0); + b.m(rx, 0.0).l(w - rx, 0.0).arc(rx, h / 2.0, 270.0, 180.0).l(rx, h).arc(rx, h / 2.0, 90.0, 180.0).z(); + simple(b.take(), Rect::new(rx * 0.3, h * 0.15, w - rx * 0.3, h * 0.85), w, h) + } + "flowChartManualInput" => simple(b.poly(&[(0.0, h / 5.0), (w, 0.0), (w, h), (0.0, h)]).take(), Rect::new(0.0, h / 5.0, w, h), w, h), + "flowChartOffpageConnector" => { + simple(b.poly(&[(0.0, 0.0), (w, 0.0), (w, h * 0.8), (w / 2.0, h), (0.0, h * 0.8)]).take(), Rect::new(0.0, 0.0, w, h * 0.8), w, h) + } + "flowChartPunchedCard" => { + simple(b.poly(&[(w * 0.2, 0.0), (w, 0.0), (w, h), (0.0, h), (0.0, h * 0.2)]).take(), Rect::new(0.0, h * 0.2, w, h), w, h) + } + "flowChartPunchedTape" => { + let a1 = h * 0.1; + b.m(0.0, a1).c(w * 0.25, a1 * 3.0, w * 0.25, -a1, w / 2.0, a1).c(w * 0.75, a1 * 3.0, w * 0.75, -a1, w, a1).l(w, h - a1); + b.c(w * 0.75, h - 3.0 * a1, w * 0.75, h + a1, w / 2.0, h - a1).c(w * 0.25, h - 3.0 * a1, w * 0.25, h + a1, 0.0, h - a1).z(); + simple(b.take(), Rect::new(0.0, h * 0.2, w, h * 0.8), w, h) + } + "flowChartSummingJunction" | "flowChartOr" => { + let e = Pb::new().ellipse(w / 2.0, h / 2.0, w / 2.0, h / 2.0).take(); + let mut l = Pb::new(); + if name == "flowChartOr" { + l.m(w / 2.0, 0.0).l(w / 2.0, h).m(0.0, h / 2.0).l(w, h / 2.0); + } else { + let (dx, dy) = (w / 2.0 * std::f64::consts::FRAC_1_SQRT_2, h / 2.0 * std::f64::consts::FRAC_1_SQRT_2); + l.m(w / 2.0 - dx, h / 2.0 - dy).l(w / 2.0 + dx, h / 2.0 + dy).m(w / 2.0 + dx, h / 2.0 - dy).l(w / 2.0 - dx, h / 2.0 + dy); + } + Geometry { + paths: vec![fill(e), stroke_only(l.take())], + text_rect: Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), + handles: vec![], + sites: ellipse_sites(w, h), + } + } + "flowChartCollate" => simple(b.poly(&[(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]).take(), Rect::new(w / 4.0, h / 4.0, w * 0.75, h * 0.75), w, h), + "flowChartOnlineStorage" => { + let rx = w / 6.0; + b.m(rx, 0.0).l(w, 0.0).arc(rx, h / 2.0, 270.0, -180.0).l(rx, h).arc(rx, h / 2.0, 90.0, 180.0).z(); + simple(b.take(), Rect::new(rx, 0.0, w - rx, h), w, h) + } + "flowChartDelay" => { + b.m(0.0, 0.0).l(w / 2.0, 0.0).arc(w / 2.0, h / 2.0, 270.0, 180.0).l(0.0, h).z(); + simple(b.take(), Rect::new(0.0, h * 0.15, w * 0.85, h * 0.85), w, h) + } + "flowChartMagneticTape" => { + b.m(w / 2.0, h).arc(w / 2.0, h / 2.0, 90.0, 90.0).arc(w / 2.0, h / 2.0, 180.0, 90.0).arc(w / 2.0, h / 2.0, 270.0, 90.0).arc( + w / 2.0, + h / 2.0, + 0.0, + 45.0, + ); + let p = b.cur; + b.l(w, p.y).l(w, h).z(); + simple(b.take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h) + } + "flowChartDisplay" => { + let rx = w / 6.0; + b.m(0.0, h / 2.0).l(rx, 0.0).l(w - rx, 0.0).arc(rx, h / 2.0, 270.0, 180.0).l(rx, h).z(); + simple(b.take(), Rect::new(rx, 0.0, w - rx, h), w, h) + } + "star4" | "star5" | "star6" | "star7" | "star8" | "star10" | "star12" | "star16" | "star24" | "star32" => { + let n: usize = name.trim_start_matches("star").parse().unwrap_or(5); + let v = a(0).clamp(0.0, 50000.0); + let inner = v / 50000.0; + let normalized = matches!(n, 5 | 7); + let mut g = simple(b.poly(&star(n, w, h, inner, normalized)).take(), full.inset(-ss * (0.5 - inner * 0.5) * 0.5), w, h); + g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); + g + } + "irregularSeal1" | "irregularSeal2" => { + let pts: &[(f64, f64)] = if name == "irregularSeal1" { + &[ + (0.48, 0.18), + (0.62, 0.0), + (0.64, 0.2), + (0.86, 0.1), + (0.78, 0.3), + (1.0, 0.36), + (0.82, 0.5), + (0.94, 0.72), + (0.7, 0.66), + (0.68, 0.9), + (0.54, 0.72), + (0.42, 1.0), + (0.36, 0.74), + (0.14, 0.86), + (0.22, 0.62), + (0.0, 0.6), + (0.18, 0.44), + (0.04, 0.24), + (0.3, 0.3), + (0.26, 0.06), + ] + } else { + &[ + (0.5, 0.16), + (0.58, 0.0), + (0.63, 0.18), + (0.78, 0.06), + (0.76, 0.24), + (0.96, 0.2), + (0.86, 0.38), + (1.0, 0.5), + (0.84, 0.58), + (0.92, 0.76), + (0.74, 0.72), + (0.76, 0.94), + (0.6, 0.8), + (0.5, 1.0), + (0.44, 0.8), + (0.28, 0.94), + (0.28, 0.74), + (0.08, 0.82), + (0.16, 0.62), + (0.0, 0.54), + (0.14, 0.44), + (0.02, 0.28), + (0.22, 0.3), + (0.2, 0.1), + (0.36, 0.22), + (0.4, 0.04), + ] + }; + let pts: Vec<(f64, f64)> = pts.iter().map(|(x, y)| (x * w, y * h)).collect(); + simple(b.poly(&pts).take(), Rect::new(w * 0.25, h * 0.3, w * 0.75, h * 0.7), w, h) + } + "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), + "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), + "wave" | "doubleWave" => { + let amp = h * a(0).clamp(0.0, 20000.0) / 1e5; + let shift = w * a(1).clamp(-10000.0, 10000.0) / 1e5; + if name == "wave" { + b.m(shift.max(0.0), amp).c(w / 3.0, -amp, 2.0 * w / 3.0, 3.0 * amp, w + shift.min(0.0), amp); + b.l(w - shift.max(0.0), h - amp).c(2.0 * w / 3.0, h - 3.0 * amp, w / 3.0, h + amp, -shift.min(0.0), h - amp).z(); + } else { + b.m(0.0, amp).c(w / 6.0, -amp, w / 3.0, 3.0 * amp, w / 2.0, amp).c(2.0 * w / 3.0, -amp, 5.0 * w / 6.0, 3.0 * amp, w, amp); + b.l(w, h - amp) + .c(5.0 * w / 6.0, h - 3.0 * amp, 2.0 * w / 3.0, h + amp, w / 2.0, h - amp) + .c(w / 3.0, h - 3.0 * amp, w / 6.0, h + amp, 0.0, h - amp) + .z(); + } + simple(b.take(), Rect::new(0.0, amp * 2.0, w, h - amp * 2.0), w, h) + } + "wedgeRectCallout" | "wedgeRoundRectCallout" => { + let r = if name == "wedgeRoundRectCallout" { ss * a(2).clamp(0.0, 50000.0) / 1e5 } else { 0.0 }; + wedge_rect(w, h, a(0), a(1), r) + } + "wedgeEllipseCallout" => { + let tip = Point::new(w / 2.0 + w * a(0) / 1e5, h / 2.0 + h * a(1) / 1e5); + let ang = (tip.y - h / 2.0).atan2(tip.x - w / 2.0).to_degrees(); + let s = vis(ang + 10.0, w / 2.0, h / 2.0); + let p0 = Point::new(w / 2.0 + w / 2.0 * s.to_radians().cos(), h / 2.0 + h / 2.0 * s.to_radians().sin()); + let e = vis(ang - 10.0, w / 2.0, h / 2.0); + let mut sw = e - s; + if sw <= 0.0 { + sw += 360.0; + } + b.m(p0.x, p0.y).arc(w / 2.0, h / 2.0, s, sw).l(tip.x, tip.y).z(); + let mut g = simple(b.take(), Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355), w, h); + g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(w / 1e5, 0.0), a(0), -1e6, 1e6)); + g.handles.push(Handle::new(1, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, h / 1e5), a(1), -1e6, 1e6)); + g.handles.retain(|_| false); + g.handles.push(tip_handle(w, h, tip)); + g + } + "borderCallout1" => { + let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take(); + let p1 = (w * a(1) / 1e5, h * a(0) / 1e5); + let p2 = (w * a(3) / 1e5, h * a(2) / 1e5); + let l = Pb::new().m(p1.0, p1.1).l(p2.0, p2.1).take(); + Geometry { paths: vec![fill(r), stroke_only(l)], text_rect: full, handles: vec![], sites: mid_sites(w, h) } + } + "line" | "straightConnector1" => { + let l = Pb::new().m(0.0, 0.0).l(w, h).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } + } + "bentConnector2" => { + let l = Pb::new().m(0.0, 0.0).l(w, 0.0).l(w, h).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } + } + "bentConnector3" | "bentConnector4" | "bentConnector5" => { + let x = w * a(0) / 1e5; + let l = Pb::new().m(0.0, 0.0).l(x, 0.0).l(x, h).l(w, h).take(); + let mut g = Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }; + g.handles.push(Handle::new(0, Point::new(0.0, h / 2.0), Vec2::new(w / 1e5, 0.0), a(0), -1e6, 1e6)); + g + } + "curvedConnector2" | "curvedConnector3" | "curvedConnector4" | "curvedConnector5" => { + let x = w * if name == "curvedConnector2" { 100000.0 } else { a(0) } / 1e5; + let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take(); + Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] } + } + _ => return None, + }; + Some(g) +} + +fn ellipse_sites(w: f64, h: f64) -> Vec { + (0..8) + .map(|i| { + let a = (-90.0 - 45.0 * i as f64).to_radians(); + Point::new(w / 2.0 + w / 2.0 * a.cos(), h / 2.0 + h / 2.0 * a.sin()) + }) + .collect() +} + +fn tip_handle(w: f64, h: f64, tip: Point) -> Handle { + // A free 2-D handle is represented as index 0 with zero direction; callers move both values. + Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6 } +} + +fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry { + let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); + let inside = tip.x > 0.0 && tip.x < w && tip.y > 0.0 && tip.y < h; + let (dx, dy) = ((tip.x - w / 2.0) / w.max(1e-9), (tip.y - h / 2.0) / h.max(1e-9)); + let mut b = Pb::new(); + let r = r.min(w / 2.0).min(h / 2.0); + // Walk clockwise from the top-left; insert the wedge on the edge facing the tip. + let horiz = dx.abs() > dy.abs(); + let (bx0, bx1) = (w * 5.0 / 12.0, w * 7.0 / 12.0); + let (by0, by1) = (h * 5.0 / 12.0, h * 7.0 / 12.0); + let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (bx0 + w / 12.0 * 2.0 - w / 12.0 * 2.0, bx1 + w * 2.0 / 12.0) }; + let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (by0, by1 + h * 2.0 / 12.0) }; + b.m(r, 0.0); + if !inside && !horiz && dy < 0.0 { + b.l(bx0, 0.0).l(tip.x, tip.y).l(bx1, 0.0); + } + b.l(w - r, 0.0); + if r > 0.0 { + b.arc(r, r, 270.0, 90.0); + } + if !inside && horiz && dx > 0.0 { + b.l(w, by0).l(tip.x, tip.y).l(w, by1); + } + b.l(w, h - r); + if r > 0.0 { + b.arc(r, r, 0.0, 90.0); + } + if !inside && !horiz && dy > 0.0 { + b.l(bx1, h).l(tip.x, tip.y).l(bx0, h); + } + b.l(r, h); + if r > 0.0 { + b.arc(r, r, 90.0, 90.0); + } + if !inside && horiz && dx < 0.0 { + b.l(0.0, by1).l(tip.x, tip.y).l(0.0, by0); + } + b.l(0.0, r); + if r > 0.0 { + b.arc(r, r, 180.0, 90.0); + } + b.z(); + let mut g = simple(b.take(), Rect::new(0.0, 0.0, w, h).inset(-r * 0.29289), w, h); + g.handles.push(tip_handle(w, h, tip)); + g +} + +fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + let n = 10; + let pts: Vec = (0..n) + .map(|i| { + let t = (i as f64 / n as f64 * 360.0 - 90.0).to_radians(); + let wob = if i % 2 == 0 { 1.0 } else { 0.92 }; + Point::new(0.5 + 0.40 * wob * t.cos(), 0.5 + 0.36 * wob * t.sin()) + }) + .collect(); + let mut b = Pb::new(); + let first = pts.first().copied().unwrap_or(Point::ZERO); + b.m(first.x, first.y); + for i in 0..n { + let p = pts.get(i).copied().unwrap_or(Point::ZERO); + let q = pts.get((i + 1) % n).copied().unwrap_or(Point::ZERO); + let mid = p.midpoint(q); + let out = (mid - Point::new(0.5, 0.5)).normalize(); + let bulge = (q - p).hypot() * 0.75; + let c1 = p + (mid - p) * 0.2 + out * bulge; + let c2 = q + (mid - q) * 0.2 + out * bulge; + b.c(c1.x, c1.y, c2.x, c2.y, q.x, q.y); + } + b.z(); + let mut path = b.take(); + let bb = path.bounding_box(); + let sx = if bb.width() > 0.0 { w / bb.width() } else { 1.0 }; + let sy = if bb.height() > 0.0 { h / bb.height() } else { 1.0 }; + path.apply_affine(kurbo::Affine::scale_non_uniform(sx, sy) * kurbo::Affine::translate(-bb.origin().to_vec2())); + let mut paths = vec![fill(path)]; + let text_rect = Rect::new(w * 0.2, h * 0.22, w * 0.8, h * 0.78); + let mut handles = vec![]; + if name == "cloudCallout" { + let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5); + let c = Point::new(w / 2.0, h / 2.0); + let mut bubbles = Pb::new(); + for (k, r) in [(0.55, 0.06), (0.75, 0.045), (0.95, 0.03)] { + let p = c + (tip - c) * k; + if !(p.x > w * 0.1 && p.x < w * 0.9 && p.y > h * 0.1 && p.y < h * 0.9) || k > 0.9 { + bubbles.ellipse(p.x, p.y, w * r, h * r); + } + } + paths.push(fill(bubbles.take())); + handles.push(tip_handle(w, h, tip)); + } + Geometry { paths, text_rect, handles, sites: mid_sites(w, h) } +} + +fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let ss = w.min(h); + let t = ss * a0.clamp(0.0, 50000.0) / 1e5; + let (cx, cy) = (w / 2.0, h / 2.0); + let mx = w * 0.0735; + let my = h * 0.0735; + let mut b = Pb::new(); + match name { + "mathPlus" => { + let (hx, hy) = (cx - mx, cy - my); + b.poly(&[ + (cx - t / 2.0, cy - hy), + (cx + t / 2.0, cy - hy), + (cx + t / 2.0, cy - t / 2.0), + (cx + hx, cy - t / 2.0), + (cx + hx, cy + t / 2.0), + (cx + t / 2.0, cy + t / 2.0), + (cx + t / 2.0, cy + hy), + (cx - t / 2.0, cy + hy), + (cx - t / 2.0, cy + t / 2.0), + (cx - hx, cy + t / 2.0), + (cx - hx, cy - t / 2.0), + (cx - t / 2.0, cy - t / 2.0), + ]); + } + "mathMinus" => { + b.poly(&[(mx, cy - t / 2.0), (w - mx, cy - t / 2.0), (w - mx, cy + t / 2.0), (mx, cy + t / 2.0)]); + } + "mathEqual" => { + let gap = h * a1.clamp(0.0, 100000.0) / 1e5; + b.poly(&[(mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0), (mx, cy - gap / 2.0)]); + b.poly(&[(mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0 + t), (mx, cy + gap / 2.0 + t)]); + } + "mathNotEqual" => { + let gap = h * a2.clamp(0.0, 100000.0) / 1e5; + b.poly(&[(mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0 - t), (w - mx, cy - gap / 2.0), (mx, cy - gap / 2.0)]); + b.poly(&[(mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0), (w - mx, cy + gap / 2.0 + t), (mx, cy + gap / 2.0 + t)]); + let ang = (a1 / 60000.0).to_radians(); + let dir = Vec2::new(ang.cos(), ang.sin()); + let len = (h / 2.0 - my) / ang.sin().abs().max(0.2); + let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0); + let c = Point::new(cx, cy); + let p = [c - dir * len - perp, c + dir * len - perp, c + dir * len + perp, c - dir * len + perp]; + b.poly(&p.map(|q| (q.x, q.y))); + } + "mathDivide" => { + let gap = h * a1.clamp(0.0, 100000.0) / 1e5; + let r = ss * a2.clamp(0.0, 50000.0) / 1e5 / 2.0; + b.poly(&[(mx, cy - t / 2.0), (w - mx, cy - t / 2.0), (w - mx, cy + t / 2.0), (mx, cy + t / 2.0)]); + b.ellipse(cx, cy - t / 2.0 - gap - r, r, r); + b.ellipse(cx, cy + t / 2.0 + gap + r, r, r); + } + _ => { + // mathMultiply: two bars crossing at the centre along the diagonals. + let len = ((cx - mx).powi(2) + (cy - my).powi(2)).sqrt(); + let c = Point::new(cx, cy); + for d in [Vec2::new(cx - mx, cy - my), Vec2::new(cx - mx, -(cy - my))] { + let dir = d / d.hypot().max(1e-9); + let perp = Vec2::new(-dir.y, dir.x) * (t / 2.0); + let p = [c - dir * len - perp, c + dir * len - perp, c + dir * len + perp, c - dir * len + perp]; + b.poly(&p.map(|q| (q.x, q.y))); + } + } + } + Geometry { paths: vec![fill(b.take())], text_rect: Rect::new(mx, cy - t / 2.0, w - mx, cy + t / 2.0), handles: vec![], sites: mid_sites(w, h) } +} + +fn ribbon(up: bool, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + let fold = h * a0.clamp(0.0, 33333.0) / 1e5; + let cw = w * a1.clamp(25000.0, 75000.0) / 1e5; // centre band width + let x0 = (w - cw) / 2.0; + let x1 = w - x0; + let ear = w / 8.0; + let (top, bot) = (0.0, h); + let mut b = Pb::new(); + let mut shade_p = Pb::new(); + if up { + // centre band sits high; ends hang low. + b.poly(&[(x0, top), (x1, top), (x1, bot - fold), (x0, bot - fold)]); + let mut ends = Pb::new(); + ends.poly(&[(0.0, fold), (x0 + ear * 0.5, fold), (x0 + ear * 0.5, bot), (0.0, bot), (ear, (fold + bot) / 2.0)]); + ends.poly(&[(w, fold), (x1 - ear * 0.5, fold), (x1 - ear * 0.5, bot), (w, bot), (w - ear, (fold + bot) / 2.0)]); + shade_p.poly(&[(x0, bot - fold), (x0 + ear * 0.5, bot - fold), (x0 + ear * 0.5, bot)]); + shade_p.poly(&[(x1, bot - fold), (x1 - ear * 0.5, bot - fold), (x1 - ear * 0.5, bot)]); + let text = Rect::new(x0, top, x1, bot - fold); + return Geometry { + paths: vec![fill(ends.take()), shade(shade_p.take(), FillMode::DarkenLess), fill(b.take())], + text_rect: text, + handles: vec![], + sites: mid_sites(w, h), + }; + } + b.poly(&[(x0, top + fold), (x1, top + fold), (x1, bot), (x0, bot)]); + let mut ends = Pb::new(); + ends.poly(&[(0.0, top), (x0 + ear * 0.5, top), (x0 + ear * 0.5, bot - fold), (0.0, bot - fold), (ear, (bot - fold) / 2.0)]); + ends.poly(&[(w, top), (x1 - ear * 0.5, top), (x1 - ear * 0.5, bot - fold), (w, bot - fold), (w - ear, (bot - fold) / 2.0)]); + shade_p.poly(&[(x0, top + fold), (x0 + ear * 0.5, top + fold), (x0 + ear * 0.5, top)]); + shade_p.poly(&[(x1, top + fold), (x1 - ear * 0.5, top + fold), (x1 - ear * 0.5, top)]); + let text = Rect::new(x0, top + fold, x1, bot); + Geometry { + paths: vec![fill(ends.take()), shade(shade_p.take(), FillMode::DarkenLess), fill(b.take())], + text_rect: text, + handles: vec![], + sites: mid_sites(w, h), + } +} + +fn scroll(vertical: bool, w: f64, h: f64, a0: f64) -> Geometry { + let ss = w.min(h); + let r = ss * a0.clamp(0.0, 25000.0) / 1e5 / 2.0; + let mut b = Pb::new(); + let mut curl = Pb::new(); + if vertical { + b.poly(&[(r, r * 2.0), (w - r, r * 2.0), (w - r, h - r), (r, h - r)]); + b.ellipse(w / 2.0, r, w / 2.0 - r * 0.0, r); + curl.ellipse(r, h - r, r, r); + curl.ellipse(w - r * 2.0, r, r * 0.5, r * 0.5); + let text = Rect::new(r, r * 2.0, w - r, h - r); + return Geometry { + paths: vec![fill(b.take()), shade(curl.take(), FillMode::DarkenLess)], + text_rect: text, + handles: vec![], + sites: mid_sites(w, h), + }; + } + b.poly(&[(r * 2.0, r), (w - r, r), (w - r, h - r), (r * 2.0, h - r)]); + b.ellipse(r, h / 2.0, r, h / 2.0); + curl.ellipse(w - r, r, r, r); + curl.ellipse(r, h - r * 2.0, r * 0.5, r * 0.5); + let text = Rect::new(r * 2.0, r, w - r, h - r); + Geometry { paths: vec![fill(b.take()), shade(curl.take(), FillMode::DarkenLess)], text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +fn action_button(name: &str, w: f64, h: f64) -> Geometry { + let body = corner_rect(w, h, [Corner::Square; 4]); + let ss = w.min(h); + let (cx, cy) = (w / 2.0, h / 2.0); + let s = ss * 0.3; + let mut g = Pb::new(); + match name { + "actionButtonBackPrevious" => { + g.poly(&[(cx + s, cy - s), (cx + s, cy + s), (cx - s, cy)]); + } + "actionButtonForwardNext" => { + g.poly(&[(cx - s, cy - s), (cx - s, cy + s), (cx + s, cy)]); + } + "actionButtonBeginning" => { + g.poly(&[(cx + s, cy - s), (cx + s, cy + s), (cx - s * 0.6, cy)]); + g.poly(&[(cx - s, cy - s), (cx - s * 0.7, cy - s), (cx - s * 0.7, cy + s), (cx - s, cy + s)]); + } + "actionButtonEnd" => { + g.poly(&[(cx - s, cy - s), (cx - s, cy + s), (cx + s * 0.6, cy)]); + g.poly(&[(cx + s * 0.7, cy - s), (cx + s, cy - s), (cx + s, cy + s), (cx + s * 0.7, cy + s)]); + } + "actionButtonHome" => { + g.poly(&[ + (cx, cy - s), + (cx + s, cy), + (cx + s * 0.7, cy), + (cx + s * 0.7, cy + s), + (cx - s * 0.7, cy + s), + (cx - s * 0.7, cy), + (cx - s, cy), + ]); + } + "actionButtonInformation" | "actionButtonHelp" => { + g.ellipse(cx, cy, s, s); + } + "actionButtonReturn" => { + g.m(cx + s, cy - s * 0.6).l(cx + s, cy + s * 0.2).q(cx + s, cy + s * 0.8, cx + s * 0.4, cy + s * 0.8).l(cx - s * 0.5, cy + s * 0.8); + g.l(cx - s * 0.5, cy + s * 0.4) + .l(cx - s, cy + s * 0.9) + .l(cx - s * 0.5, cy + s * 1.4) + .l(cx - s * 0.5, cy + s) + .l(cx + s * 0.6, cy + s) + .l(cx + s * 0.6, cy - s * 0.6) + .z(); + } + "actionButtonMovie" => { + g.poly(&[(cx - s, cy - s * 0.6), (cx + s * 0.4, cy - s * 0.6), (cx + s * 0.4, cy + s * 0.6), (cx - s, cy + s * 0.6)]); + g.poly(&[(cx + s * 0.4, cy), (cx + s, cy - s * 0.5), (cx + s, cy + s * 0.5)]); + } + "actionButtonDocument" => { + g.poly(&[(cx - s * 0.7, cy - s), (cx + s * 0.3, cy - s), (cx + s * 0.7, cy - s * 0.6), (cx + s * 0.7, cy + s), (cx - s * 0.7, cy + s)]); + } + "actionButtonSound" => { + g.poly(&[ + (cx - s, cy - s * 0.35), + (cx - s * 0.5, cy - s * 0.35), + (cx, cy - s), + (cx, cy + s), + (cx - s * 0.5, cy + s * 0.35), + (cx - s, cy + s * 0.35), + ]); + } + _ => {} + } + Geometry { + paths: vec![fill(body), shade(g.take(), FillMode::DarkenLess)], + text_rect: Rect::new(0.0, 0.0, w, h), + handles: vec![], + sites: mid_sites(w, h), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape; + + #[test] + fn every_catalog_preset_builds_within_bounds() { + for p in CATALOG { + for (w, h) in [(200.0, 100.0), (100.0, 200.0), (50.0, 50.0), (0.0, 0.0), (1.0, 300.0)] { + let g = build(p.name, w, h, &[]).unwrap_or_else(|| panic!("{} did not build", p.name)); + assert!(!g.paths.is_empty(), "{} has no paths", p.name); + for s in &g.paths { + let bb = s.path.bounding_box(); + if w > 0.0 && h > 0.0 && !p.name.contains("Connector") && !p.name.contains("allout") { + let tol = w.max(h) * 0.35 + 1.0; + assert!(bb.x0 >= -tol && bb.y0 >= -tol && bb.x1 <= w + tol && bb.y1 <= h + tol, "{} {w}x{h} out of bounds: {bb:?}", p.name); + } + assert!(bb.x0.is_finite() && bb.y1.is_finite(), "{} produced non-finite geometry", p.name); + } + assert!(g.text_rect.x0.is_finite() && g.text_rect.y1.is_finite(), "{}", p.name); + } + } + } + + #[test] + fn hostile_adjust_values_never_panic() { + for p in CATALOG { + for v in [f64::NAN, f64::INFINITY, -1e12, 1e12, -1.0, 0.0] { + let adj = vec![v; 6]; + let g = build(p.name, 120.0, 80.0, &adj); + assert!(g.is_some(), "{}", p.name); + } + } + assert!(build("rect", f64::NAN, 10.0, &[]).is_none()); + assert!(build("noSuchShape", 10.0, 10.0, &[]).is_none()); + } + + #[test] + fn catalog_names_are_unique() { + let mut names: Vec<&str> = CATALOG.iter().map(|p| p.name).collect(); + names.sort_unstable(); + let n = names.len(); + names.dedup(); + assert_eq!(n, names.len()); + assert!(n >= 140, "catalog has {n} presets"); + } + + #[test] + fn round_rect_handle_maps_back() { + let g = build("roundRect", 200.0, 100.0, &[16667.0]).unwrap(); + let h = g.handles.first().unwrap(); + assert!((h.value_at(h.pos) - 16667.0).abs() <= 1.0); + assert_eq!(h.value_at(Point::new(-50.0, 0.0)), 0.0); + assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0); + } + + #[test] + fn rect_area_and_ellipse_area() { + let g = build("rect", 100.0, 50.0, &[]).unwrap(); + assert!((g.paths[0].path.area().abs() - 5000.0).abs() < 1e-6); + let e = build("ellipse", 100.0, 50.0, &[]).unwrap(); + let expect = std::f64::consts::PI * 50.0 * 25.0; + assert!((e.paths[0].path.area().abs() - expect).abs() / expect < 0.01); + } + + #[test] + fn lines_are_open() { + assert!(build("line", 10.0, 10.0, &[]).unwrap().is_open()); + assert!(!build("rect", 10.0, 10.0, &[]).unwrap().is_open()); + } +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 0000000..109669b --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "slidecraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft MCP server: JSON-RPC 2.0 over stdio, driving a running app or a headless session" + +[lints] +workspace = true + +[dependencies] +slidecraft-engine = { workspace = true } +slidecraft-render = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 0000000..8609e94 --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,107 @@ +//! Where MCP tool calls end up: a control-channel method call. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Something that answers control-channel methods (`engine.execute`, `document.inspect`, +/// `ui.pointer`, `ui.render`, …). See `slidecraft_ui_egui::control` for the full list. +pub trait Backend { + /// Call one method. `Ok` carries the `result`, `Err` the error message. + fn call(&mut self, method: &str, params: Value) -> Result; + /// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work). + fn has_ui(&self) -> bool; + /// Short human description ("headless", "connected to 127.0.0.1:7979"). + fn describe(&self) -> String; +} + +/// A running SlideCraft app, reached through its loopback control port. +pub struct Remote { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl Remote { + /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. + pub fn connect(addr: &str) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + r.reconnect()?; + Ok(r) + } + + pub fn addr(&self) -> &str { + &self.addr + } + + fn reconnect(&mut self) -> std::io::Result<()> { + self.conn = None; + let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); + for sa in self.addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { + Ok(s) => { + s.set_nodelay(true).ok(); + // The app answers within 60 s (its own timeout); leave headroom. + s.set_read_timeout(Some(Duration::from_secs(90))).ok(); + let read = s.try_clone()?; + self.conn = Some((BufReader::new(read), s)); + return Ok(()); + } + Err(e) => last = e, + } + } + Err(last) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { + if self.conn.is_none() { + self.reconnect()?; + } + let Some((reader, writer)) = self.conn.as_mut() else { + return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); + }; + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + if reader.read_line(&mut reply)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + } + Ok(reply) + } +} + +impl Backend for Remote { + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let line = json!({"id": id, "method": method, "params": params}).to_string(); + // One retry with a fresh connection (the app may have restarted). + let reply = match self.roundtrip(&line) { + Ok(r) => r, + Err(_) => { + self.conn = None; + self.roundtrip(&line).map_err(|e| { + self.conn = None; + format!("SlideCraft app at {} is not reachable: {e}", self.addr) + })? + } + }; + let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + if v.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } else { + Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string()) + } + } + + fn has_ui(&self) -> bool { + true + } + + fn describe(&self) -> String { + format!("connected to the SlideCraft app at {}", self.addr) + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 0000000..15435ea --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,158 @@ +//! An in-process engine session that answers the engine-level control-channel methods itself. + +use serde_json::{Value, json}; +use slidecraft_engine::{Mods, PointerEvent, PointerKind, Session, ToolKind}; + +use crate::backend::Backend; + +/// Headless backend: a [`Session`] rendering slides on the CPU. +pub struct Headless { + pub session: Session, +} + +impl Default for Headless { + fn default() -> Self { + Self::new() + } +} + +/// Hint appended to errors for methods that need a window. +pub(crate) const NEEDS_APP: &str = "it needs the desktop app: start `slidecraft --control 7979` and run the MCP server \ +with `slidecraft-cli mcp --connect 7979`"; + +fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +pub fn pointer_kind(k: &str) -> Option { + Some(match k { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + "tripleclick" => PointerKind::TripleClick, + _ => return None, + }) +} + +pub fn mods_from(p: &Value) -> Mods { + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd") || b("ctrl") } +} + +/// A tool name (`select`, `textBox`, `shape`, `pen`, `highlighter`, `eraser`) to a [`ToolKind`]. +pub fn tool_kind(name: &str, preset: Option<&str>) -> Option { + Some(match name { + "select" | "selection" | "arrow" => ToolKind::Select, + "textBox" | "text" | "textbox" => ToolKind::TextBox, + "shape" => ToolKind::Shape { preset: preset.unwrap_or("rect").to_string() }, + "pen" => ToolKind::Ink { mode: "pen".into(), color: slidecraft_engine::model::Rgba::BLACK, width: 2.0 }, + "highlighter" => ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_engine::model::Rgba::rgb(255, 230, 0), width: 10.0 }, + "eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_engine::model::Rgba::BLACK, width: 2.0 }, + other if slidecraft_engine::geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.to_string() }, + _ => return None, + }) +} + +impl Headless { + pub fn new() -> Self { + Self { session: Session::new() } + } + + /// A session with a fresh presentation. + pub fn with_document() -> Self { + Self { session: Session::with_new() } + } + + fn exec(&mut self, id: &str, params: &Value) -> Result { + let r = self.session.execute(id, params).map_err(|e| { + let ui_only = ["app.", "view.", "window.", "ui."].iter().any(|p| id.starts_with(p)); + if ui_only && slidecraft_engine::find_command(id).is_none() { format!("`{id}` is a UI command; {NEEDS_APP}") } else { e.to_string() } + }); + self.session.ui_requests.clear(); + r + } + + fn pointer(&mut self, p: &Value) -> Result { + let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?; + let base = p.get("mods").map(mods_from).unwrap_or_default(); + if self.session.active().is_none() { + return Err("no presentation open (use new_presentation first)".into()); + } + let mut last = Value::Null; + for e in events { + let kind = s(e, "kind").unwrap_or(""); + let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?; + let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?; + let mods = e.get("mods").map(mods_from).unwrap_or(base); + last = self.session.pointer(PointerEvent { kind, x, y, mods, tol: 3.0 }).map_err(|e| e.to_string())?; + } + self.session.ui_requests.clear(); + Ok(json!({"result": last, "selection": self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())})) + } + + fn render(&mut self, p: &Value) -> Result { + let st = self.session.active().ok_or("no presentation open")?; + let i = p.get("slide").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(st.selection.slide); + if i >= st.doc.slides.len() { + return Err(format!("no slide {i}")); + } + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.05, 8.0); + let img = slidecraft_engine::render::render_slide( + &st.doc, + i, + &slidecraft_engine::render::RenderOpts { scale, edit: p.get("edit").and_then(Value::as_bool).unwrap_or(false), ..Default::default() }, + ); + let png = img.to_png(); + if let Some(path) = s(p, "path") { + std::fs::write(path, &png).map_err(|e| format!("{path}: {e}"))?; + return Ok(json!({"path": path, "width": img.width, "height": img.height})); + } + Ok(json!({"png": slidecraft_engine::cmd::base64_encode(&png), "width": img.width, "height": img.height, "slide": i})) + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let p = ¶ms; + match method { + "engine.execute" | "command" | "ui.menu.invoke" => { + let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string(); + let cp = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + self.exec(&id, &cp) + } + "engine.commands" => Ok(serde_json::to_value(self.session.commands()).unwrap_or_default()), + "document.inspect" => self.exec("document.inspect", &json!({})), + "ui.pointer" => self.pointer(p), + "ui.key" => { + let key = s(p, "key").ok_or("missing `key`")?; + self.session.key(key, mods_from(p)).map_err(|e| e.to_string()) + } + "ui.text" => { + let t = s(p, "text").ok_or("missing `text`")?; + self.session.type_text(t).map_err(|e| e.to_string()) + } + "ui.tool.select" => { + let name = s(p, "tool").ok_or("missing `tool`")?; + let kind = tool_kind(name, s(p, "preset")).ok_or_else(|| format!("unknown tool `{name}`"))?; + self.session.set_tool(kind); + Ok(json!({"tool": name})) + } + "ui.render" | "app.renderSlide" => self.render(p), + "app.open" => self.exec("file.open", p), + "app.save" => self.exec("file.save", p), + "app.export" => self.exec("file.export", p), + "ui.inspect" => Ok(json!({"headless": true, "tool": serde_json::to_value(&self.session.tool.kind).unwrap_or_default()})), + m if m.starts_with("ui.") || m.starts_with("app.") => Err(format!("`{m}`: {NEEDS_APP}")), + other => Err(format!("unknown method `{other}`")), + } + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "headless (in-process engine; no window)".into() + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 0000000..08123f8 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,37 @@ +//! SlideCraft's MCP server. +//! +//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited +//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes SlideCraft as a set of MCP +//! tools and resources and forwards everything to a [`Backend`]: +//! +//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel +//! (`slidecraft --control 7979`): one `{"id","method","params"}` line in, one +//! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`). +//! - [`Headless`] hosts an in-process [`slidecraft_engine::Session`] and implements the +//! engine-level control-channel methods itself (rendering pages with `slidecraft-render`), so +//! agents can build layouts and look at them without a window. +//! +//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod backend; +mod headless; +mod server; +mod tools; + +pub use backend::{Backend, Remote}; +pub use headless::Headless; +pub use server::{PROTOCOL_VERSION, Server}; +pub use tools::{ToolResult, call_tool, tool_definitions}; + +/// Default control-channel port of the desktop app. +pub const DEFAULT_PORT: u16 = 7979; + +/// `"7979"` → `"127.0.0.1:7979"`; `"host:port"` is kept as is. +pub fn control_addr(s: &str) -> String { + if s.parse::().is_ok() { format!("127.0.0.1:{s}") } else { s.to_string() } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 0000000..1e231ff --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,173 @@ +//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods. + +use std::io::{BufRead, Write}; + +use serde_json::{Value, json}; + +use crate::backend::Backend; +use crate::tools::{call_tool, tool_definitions}; + +/// The MCP revision we implement. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; +const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; + +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; +const RESOURCE_NOT_FOUND: i64 = -32002; + +const INSTRUCTIONS: &str = "SlideCraft is a presentation app (a PowerPoint clone). Coordinates are points on the \ +current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \ +list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \ +{layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \ +inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \ +run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \ +on `ids`. Save with save_presentation (.slidecraft or .pptx)."; + +/// Resource URIs. +pub const DOC_URI: &str = "slidecraft://document"; +pub const COMMANDS_URI: &str = "slidecraft://commands"; + +/// An MCP server bound to one backend. +pub struct Server { + backend: Box, + initialized: bool, +} + +fn response(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn error(id: Value, code: i64, message: impl Into) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) +} + +impl Server { + pub fn new(backend: Box) -> Self { + Self { backend, initialized: false } + } + + pub fn backend(&mut self) -> &mut dyn Backend { + self.backend.as_mut() + } + + /// Whether the client has sent `notifications/initialized`. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is + /// the protocol stream). + pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + for line in input.lines() { + let line = line?; + if let Some(reply) = self.handle_line(&line) { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + Ok(()) + } + + /// Handle one line; returns the reply line (None for notifications and blank lines). + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let reply = match serde_json::from_str::(line) { + Ok(Value::Array(batch)) => { + // Batches were removed in 2025-06-18; still answer older clients sensibly. + if batch.is_empty() { + Some(error(Value::Null, INVALID_REQUEST, "empty batch")) + } else { + let replies: Vec = batch.into_iter().filter_map(|m| self.handle(m)).collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) + } + } + Ok(msg) => self.handle(msg), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// Handle one JSON-RPC message; `None` for notifications and responses. + pub fn handle(&mut self, msg: Value) -> Option { + let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) }; + let id = o.get("id").cloned(); + let Some(method) = o.get("method").and_then(Value::as_str) else { + // A response to a server→client request (we send none): ignore. Anything else is invalid. + if o.contains_key("result") || o.contains_key("error") { + return None; + } + return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`")); + }; + let params = o.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + self.notification(method); + return None; + }; + if !(id.is_string() || id.is_number()) { + return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number")); + } + Some(match self.request(method, ¶ms) { + Ok(r) => response(id, r), + Err((code, m)) => error(id, code, m), + }) + } + + fn notification(&mut self, method: &str) { + match method { + "notifications/initialized" => self.initialized = true, + "notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {} + other => log::debug!("ignoring notification {other}"), + } + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); + let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {}, "resources": {}}, + "serverInfo": {"name": "slidecraft", "title": "SlideCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": slidecraft_engine::links::APP_PAGE}, + "instructions": format!( + "{INSTRUCTIONS} Backend: {}. Community: {} (Discord), {} (app page), {} (source). The `app.links` command returns these links.", + self.backend.describe(), + slidecraft_engine::links::DISCORD, + slidecraft_engine::links::APP_PAGE, + slidecraft_engine::links::GITHUB + ), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + } + "resources/list" => Ok(json!({"resources": [ + {"uri": DOC_URI, "name": "document", "title": "Active document", "description": "Slides (titles, layouts, shapes), sections, theme, media and selection of the active presentation (document.inspect)", "mimeType": "application/json"}, + {"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, menu path, shortcut, params, enabled (engine.commands)", "mimeType": "application/json"}, + ]})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "resources/read" => { + let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; + let v = match uri { + DOC_URI => self.backend.call("document.inspect", json!({})), + COMMANDS_URI => self.backend.call("engine.commands", json!({})), + _ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))), + } + .map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) + } + other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 0000000..806fff7 --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,91 @@ +//! An agent builds a small deck entirely through MCP and checks its work only through MCP. + +use serde_json::{Value, json}; + +use crate::{Headless, Server}; + +fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let out = s.handle_line(&line).expect("a response"); + serde_json::from_str(&out).expect("json") +} + +fn tool(s: &mut Server, name: &str, args: Value) -> Value { + let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args})); + let res = &r["result"]; + assert_eq!(res["isError"], false, "{name} failed: {res}"); + let text = + res["content"].as_array().unwrap().iter().find(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).unwrap_or_default(); + serde_json::from_str(&text).unwrap_or(Value::String(text)) +} + +#[test] +fn handshake_and_tool_list() { + let mut s = Server::new(Box::new(Headless::new())); + let r = call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"})); + assert_eq!(r["result"]["serverInfo"]["name"], "slidecraft"); + let tools = call(&mut s, 2, "tools/list", json!({})); + let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + for n in ["run_command", "render_slide", "add_slide", "add_shape", "pointer", "inspect_slide"] { + assert!(names.contains(&n), "{n}"); + } + // Garbage never panics. + assert!(s.handle_line("not json").is_some()); + assert!(s.handle_line("{}").is_some()); +} + +#[test] +fn agent_builds_a_deck() { + let mut s = Server::new(Box::new(Headless::new())); + tool(&mut s, "new_presentation", json!({"theme": "Harbor"})); + let doc = tool(&mut s, "inspect_document", json!({})); + let title_id = { + let sl = tool(&mut s, "inspect_slide", json!({})); + sl["shapes"][0]["id"].as_u64().unwrap() + }; + let _ = doc; + tool(&mut s, "set_text", json!({"id": title_id, "text": "Quarterly Review"})); + tool(&mut s, "add_slide", json!({"layout": "titleAndContent", "title": "Highlights", "body": "Revenue up\n\tNew markets\nCosts down"})); + let shape = tool(&mut s, "add_shape", json!({"preset": "roundRect", "x": 600, "y": 300, "w": 200, "h": 100, "text": "Goal", "fill": "accent2"})); + let sid = shape["id"].as_u64().unwrap(); + tool(&mut s, "run_command", json!({"command": "shape.rotate", "params": {"id": sid, "deg": 10}})); + // Drag the shape with the pointer. + tool( + &mut s, + "pointer", + json!({"events": [{"kind": "down", "x": 700, "y": 350}, {"kind": "drag", "x": 650, "y": 330}, {"kind": "up", "x": 650, "y": 330}]}), + ); + tool(&mut s, "insert_chart", json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]})); + let sl = tool(&mut s, "inspect_slide", json!({})); + assert_eq!(sl["title"], "Highlights"); + let shapes = sl["shapes"].as_array().unwrap(); + let goal = shapes.iter().find(|x| x["id"].as_u64() == Some(sid)).unwrap(); + assert_eq!(goal["text"], "Goal"); + assert_eq!(goal["rotation"], 10.0); + assert!(goal["box"][0].as_f64().unwrap() < 600.0, "moved left: {goal}"); + assert!(shapes.iter().any(|x| x["kind"] == "chart")); + let doc = tool(&mut s, "inspect_document", json!({})); + assert_eq!(doc["slides"].as_array().unwrap().len(), 2); + assert_eq!(doc["theme"], "Harbor"); + // Look at it. + let r = call(&mut s, 10, "tools/call", json!({"name": "render_slide", "arguments": {"scale": 0.25}})); + assert_eq!(r["result"]["content"][0]["type"], "image"); + // Keyboard: select the shape, delete it, undo. + tool(&mut s, "select", json!({"ids": [sid]})); + tool(&mut s, "key", json!({"key": "Delete"})); + let n1 = tool(&mut s, "inspect_slide", json!({}))["shapes"].as_array().unwrap().len(); + tool(&mut s, "key", json!({"key": "z", "mods": {"cmd": true}})); + let n2 = tool(&mut s, "inspect_slide", json!({}))["shapes"].as_array().unwrap().len(); + assert_eq!(n2, n1 + 1); +} + +#[test] +fn errors_are_reported_not_panics() { + let mut s = Server::new(Box::new(Headless::new())); + let r = call(&mut s, 1, "tools/call", json!({"name": "run_command", "arguments": {"command": "no.such"}})); + assert_eq!(r["result"]["isError"], true); + let r = call(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}})); + assert_eq!(r["result"]["isError"], true); + let r = call(&mut s, 3, "tools/call", json!({"name": "add_shape", "arguments": {"preset": "rect"}})); + assert_eq!(r["result"]["isError"], true); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 0000000..9cc12ea --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,422 @@ +//! MCP tool definitions (names, descriptions, JSON Schemas) and their implementations on top of a +//! [`Backend`]. + +use serde_json::{Value, json}; + +use crate::backend::Backend; +use crate::headless::NEEDS_APP; + +/// The result of `tools/call`: MCP content blocks plus the `isError` flag. +#[derive(Clone, Debug, PartialEq)] +pub struct ToolResult { + pub content: Vec, + pub is_error: bool, +} + +impl ToolResult { + pub fn text(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false } + } + pub fn json(v: &Value) -> Self { + Self::text(serde_json::to_string_pretty(v).unwrap_or_default()) + } + pub fn error(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true } + } + pub fn image(png_base64: String, info: &Value) -> Self { + Self { + content: vec![json!({"type": "image", "data": png_base64, "mimeType": "image/png"}), json!({"type": "text", "text": info.to_string()})], + is_error: false, + } + } + pub fn to_value(&self) -> Value { + json!({"content": self.content, "isError": self.is_error}) + } +} + +fn num(desc: &str) -> Value { + json!({"type": "number", "description": desc}) +} +fn int(desc: &str) -> Value { + json!({"type": "integer", "minimum": 0, "description": desc}) +} +fn string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn boolean(desc: &str) -> Value { + json!({"type": "boolean", "description": desc}) +} +fn mods_schema() -> Value { + json!({"type": "object", "description": "Modifier keys (cmd = Command on macOS / Ctrl elsewhere; alt = Option)", "properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}}}) +} +fn obj(props: Value, required: &[&str]) -> Value { + let mut o = json!({"type": "object", "properties": props}); + if !required.is_empty() { + o["required"] = json!(required); + } + o +} +fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value { + json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": read_only, "openWorldHint": false}}) +} + +const APP_ONLY: &str = " Desktop app only (`slidecraft-cli mcp --connect PORT`)."; + +pub fn tool_definitions() -> Vec { + vec![ + tool( + "list_commands", + "List commands", + "Every command (id, label, ribbon/menu place, shortcut, parameter summary, whether it is enabled now). Filter by a substring of id or label.", + obj(json!({"filter": string("Substring of the id or label, e.g. `format.` or `slide`")}), &[]), + true, + ), + tool( + "run_command", + "Run a command", + "Run any command by id with JSON parameters. This is how everything is done: slide.new, shape.insert, text.set, format.bold, shape.fill, design.theme, transition.set, animation.add, table.merge, edit.undo…", + obj( + json!({"command": string("Command id from list_commands"), "params": {"type": "object", "description": "Parameters (see the command's params summary)"}}), + &["command"], + ), + false, + ), + tool( + "batch", + "Run several commands", + "Run commands in order; stops at the first error unless keepGoing. Returns each result.", + obj( + json!({"commands": {"type": "array", "items": obj(json!({"command": string("id"), "params": {"type": "object"}}), &["command"])}, "keepGoing": boolean("Continue after errors")}), + &["commands"], + ), + false, + ), + tool( + "new_presentation", + "New presentation", + "Start a new presentation (one title slide). Optional theme: SlideCraft, Harbor, Ember, Meadow, Nocturne, Paper, Coral Reef, Slate.", + obj(json!({"theme": string("Theme name"), "blank": boolean("No first slide")}), &[]), + false, + ), + tool( + "open_presentation", + "Open presentation", + "Open a .slidecraft or .pptx file by path.", + obj(json!({"path": string("File path")}), &["path"]), + false, + ), + tool( + "save_presentation", + "Save presentation", + "Save the active presentation (.slidecraft or .pptx by extension).", + obj(json!({"path": string("File path (optional when it was opened from a file)")}), &[]), + false, + ), + tool( + "export", + "Export", + "Export slides: png/jpeg (one slide, or all with all:true), pptx, outline text.", + obj( + json!({"path": string("Output path"), "format": string("png|jpeg|pptx|slidecraft|outline"), "slide": int("Slide index for images"), "all": boolean("All slides (images)"), "scale": num("Pixels per point for images (default 2)")}), + &["path"], + ), + false, + ), + tool( + "inspect_document", + "Inspect presentation", + "Slides (index, id, title, layout, shape count, transition, animations, notes), sections, theme, layouts, media and the selection.", + obj(json!({}), &[]), + true, + ), + tool( + "inspect_slide", + "Inspect slide", + "One slide's shapes with ids, kinds, boxes [x,y,w,h], placeholder types, text, fills, tables, charts; plus background, transition, animations, notes.", + obj(json!({"index": int("Slide index (default: current)")}), &[]), + true, + ), + tool( + "render_slide", + "Render slide", + "Render a slide to a PNG image and return it, to check your work visually.", + obj( + json!({"slide": int("Slide index (default: current)"), "scale": num("Pixels per point (default 1 = 960×540 for 16:9)"), "edit": boolean("Show placeholder prompts like the editor")}), + &[], + ), + true, + ), + tool( + "add_slide", + "Add slide", + "Insert a slide after the current one with a layout (title, titleAndContent, sectionHeader, twoContent, comparison, titleOnly, blank, contentWithCaption, pictureWithCaption) and optional title/body text (body: one paragraph per line, leading tabs indent).", + obj(json!({"layout": string("Layout name or kind"), "title": string("Title text"), "body": string("Body text")}), &[]), + false, + ), + tool( + "go_to_slide", + "Go to slide", + "Make a slide current (shape commands act on the current slide).", + obj(json!({"index": int("Slide index")}), &["index"]), + false, + ), + tool( + "add_shape", + "Add shape", + "Insert a preset shape (rect, roundRect, ellipse, triangle, diamond, rightArrow, chevron, star5, heart, cloud, wedgeRectCallout, line, straightConnector1… see shape.presets) at x,y with size w×h, with optional text and fill colour (#RRGGBB or accent1..6).", + obj( + json!({"preset": string("Preset name"), "x": num("Left (pt)"), "y": num("Top (pt)"), "w": num("Width (pt)"), "h": num("Height (pt)"), "text": string("Text inside"), "fill": string("Fill colour"), "line": string("Outline colour or `none`")}), + &["preset", "x", "y", "w", "h"], + ), + false, + ), + tool( + "add_text_box", + "Add text box", + "Insert a text box. Height grows to fit the text.", + obj( + json!({"x": num("Left (pt)"), "y": num("Top (pt)"), "w": num("Width (pt)"), "h": num("Height (pt)"), "text": string("Text (one paragraph per line)"), "size": num("Font size (pt)"), "color": string("Text colour"), "align": string("left|center|right|justify")}), + &["x", "y", "w", "text"], + ), + false, + ), + tool( + "set_text", + "Set text", + "Replace a shape's text (keeps its first run's formatting). Leading tabs set bullet levels.", + obj(json!({"id": int("Shape id (from inspect_slide)"), "text": string("New text")}), &["id", "text"]), + false, + ), + tool( + "insert_picture", + "Insert picture", + "Insert a picture from a file path or base64 data, optionally at a box.", + obj( + json!({"path": string("Image file path"), "data": string("Base64 image data (instead of path)"), "name": string("File name for data"), "x": num("Left"), "y": num("Top"), "w": num("Width"), "h": num("Height")}), + &[], + ), + false, + ), + tool( + "insert_table", + "Insert table", + "Insert a table with rows×cols and optional cell text (data: rows of strings).", + obj( + json!({"rows": int("Rows"), "cols": int("Columns"), "data": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}}, "x": num("Left"), "y": num("Top"), "w": num("Width"), "h": num("Height")}), + &["rows", "cols"], + ), + false, + ), + tool( + "insert_chart", + "Insert chart", + "Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series.", + obj( + json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}}), + &[], + ), + false, + ), + tool( + "select", + "Select shapes", + "Select shapes by id (formatting commands then act on them).", + obj(json!({"ids": {"type": "array", "items": {"type": "integer"}}}), &["ids"]), + false, + ), + tool( + "pointer", + "Pointer gesture", + "Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret).", + obj( + json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}), + &["events"], + ), + false, + ), + tool( + "select_tool", + "Select tool", + "Pick the pointer tool: select, textBox, shape (with preset), pen, highlighter, eraser — or a preset name.", + obj(json!({"tool": string("Tool name"), "preset": string("Shape preset for the shape tool")}), &["tool"]), + false, + ), + tool( + "key", + "Press key", + "Press a key as on the keyboard: text editing keys (arrows, Backspace, Enter, Tab), nudges, and shortcuts (cmd+B…).", + obj(json!({"key": string("Key name or character"), "mods": mods_schema()}), &["key"]), + false, + ), + tool( + "type_text", + "Type text", + "Type text into the text being edited (or into the selected shape).", + obj(json!({"text": string("Text")}), &["text"]), + false, + ), + tool("screenshot", "Screenshot", &format!("Screenshot of the whole SlideCraft window (UI included).{APP_ONLY}"), obj(json!({}), &[]), true), + tool( + "call_app", + "Call app method", + &format!("Call any control-channel method (ui.click, ui.set, ui.dialog.*, ui.inspect…).{APP_ONLY}"), + obj(json!({"method": string("Method"), "params": {"type": "object"}}), &["method"]), + false, + ), + ] +} + +fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { + b.call("engine.execute", json!({"command": cmd, "params": params})) +} + +fn f(a: &Value, k: &str) -> Option { + a.get(k).and_then(Value::as_f64) +} + +fn wrap(r: Result) -> ToolResult { + match r { + Ok(v) => ToolResult::json(&v), + Err(e) => ToolResult::error(e), + } +} + +pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { + let a = if a.is_object() { a.clone() } else { json!({}) }; + match name { + "list_commands" => wrap(b.call("engine.commands", json!({})).map(|v| { + let f = a.get("filter").and_then(Value::as_str).unwrap_or("").to_lowercase(); + Value::Array( + v.as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|c| { + f.is_empty() + || c.get("id").and_then(Value::as_str).is_some_and(|i| i.to_lowercase().contains(&f)) + || c.get("label").and_then(Value::as_str).is_some_and(|i| i.to_lowercase().contains(&f)) + }) + .collect(), + ) + })), + "run_command" => { + let Some(c) = a.get("command").and_then(Value::as_str) else { return ToolResult::error("missing `command`") }; + wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))) + } + "batch" => { + let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false); + let mut out = vec![]; + for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() { + let id = c.get("command").and_then(Value::as_str).unwrap_or(""); + match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) { + Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})), + Err(e) => { + out.push(json!({"command": id, "ok": false, "error": e})); + if !keep { + return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true }; + } + } + } + } + ToolResult::json(&Value::Array(out)) + } + "new_presentation" => wrap(exec(b, "file.new", a)), + "open_presentation" => wrap(exec(b, "file.open", a)), + "save_presentation" => wrap(exec(b, "file.save", a)), + "export" => wrap(exec(b, "file.export", a)), + "inspect_document" => wrap(b.call("document.inspect", json!({}))), + "inspect_slide" => wrap(exec(b, "slide.inspect", a)), + "render_slide" => match b.call("ui.render", a.clone()) { + Ok(v) => match v.get("png").and_then(Value::as_str) { + Some(png) => { + ToolResult::image(png.to_string(), &json!({"width": v.get("width"), "height": v.get("height"), "slide": v.get("slide")})) + } + None => ToolResult::json(&v), + }, + Err(e) => ToolResult::error(e), + }, + "add_slide" => wrap(exec(b, "slide.new", a)), + "go_to_slide" => wrap(exec(b, "slide.go", a)), + "add_shape" => { + let r = exec( + b, + "shape.insert", + json!({"preset": a.get("preset"), "rect": [f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")], "text": a.get("text")}), + ); + let Ok(v) = r else { return wrap(r) }; + let id = v.get("id").cloned().unwrap_or_default(); + if let Some(c) = a.get("fill").filter(|c| c.is_string()) + && let Err(e) = exec(b, "shape.fill", json!({"id": id, "color": c})) + { + return ToolResult::error(e); + } + if let Some(c) = a.get("line").and_then(Value::as_str) { + let p = if c == "none" { json!({"id": id, "none": true}) } else { json!({"id": id, "color": c}) }; + if let Err(e) = exec(b, "shape.line", p) { + return ToolResult::error(e); + } + } + ToolResult::json(&v) + } + "add_text_box" => { + let r = + exec(b, "insert.textBox", json!({"rect": [f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h").unwrap_or(30.0)], "text": a.get("text")})); + let Ok(v) = r else { return wrap(r) }; + let id = v.get("id").cloned().unwrap_or_default(); + let _ = exec(b, "text.exit", json!({})); + let _ = exec(b, "edit.select", json!({"ids": [id]})); + if let Some(s) = f(&a, "size") { + let _ = exec(b, "format.size", json!({"size": s})); + } + if let Some(c) = a.get("color").filter(|c| c.is_string()) { + let _ = exec(b, "format.color", json!({"color": c})); + } + if let Some(al) = a.get("align").and_then(Value::as_str) { + let _ = exec(b, "format.align", json!({"align": al})); + } + ToolResult::json(&v) + } + "set_text" => wrap(exec(b, "text.set", a)), + "insert_picture" => { + let mut p = a.clone(); + if let (Some(x), Some(y), Some(w), Some(h)) = (f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")) { + p["rect"] = json!([x, y, w, h]); + } + wrap(exec(b, "insert.picture", p)) + } + "insert_table" => { + let mut p = a.clone(); + if let (Some(x), Some(y), Some(w), Some(h)) = (f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")) { + p["rect"] = json!([x, y, w, h]); + } + wrap(exec(b, "insert.table", p)) + } + "insert_chart" => wrap(exec(b, "insert.chart", a)), + "select" => wrap(exec(b, "edit.select", a)), + "pointer" => wrap(b.call("ui.pointer", a)), + "select_tool" => wrap(b.call("ui.tool.select", a)), + "key" => { + let mut p = a.get("mods").cloned().filter(Value::is_object).unwrap_or(json!({})); + p["key"] = a.get("key").cloned().unwrap_or_default(); + wrap(b.call("ui.key", p)) + } + "type_text" => wrap(b.call("ui.text", a)), + "screenshot" => { + if !b.has_ui() { + return ToolResult::error(format!("screenshot: {NEEDS_APP}. Use render_slide to see a slide headlessly.")); + } + match b.call("ui.screenshot", json!({"base64": true})) { + Ok(v) => match v.get("png").and_then(Value::as_str) { + Some(png) => ToolResult::image(png.to_string(), &json!({"width": v.get("width"), "height": v.get("height")})), + None => ToolResult::json(&v), + }, + Err(e) => ToolResult::error(e), + } + } + "call_app" => { + let Some(m) = a.get("method").and_then(Value::as_str) else { return ToolResult::error("missing `method`") }; + wrap(b.call(m, a.get("params").cloned().unwrap_or(json!({})))) + } + other => ToolResult::error(format!("unknown tool `{other}`")), + } +} diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 0000000..c0db212 --- /dev/null +++ b/crates/model/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "slidecraft-model" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft document model: presentations, masters, layouts, slides, shapes, text, themes, transitions, animations" + +[lints] +workspace = true + +[dependencies] +slidecraft-geom = { workspace = true } +slidecraft-color = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/model/src/anim.rs b/crates/model/src/anim.rs new file mode 100644 index 0000000..3495d21 --- /dev/null +++ b/crates/model/src/anim.rs @@ -0,0 +1,293 @@ +//! Slide transitions and object animations. +//! +//! Animations are kept as the flat, ordered list the Animation Pane shows; the PPTX writer builds +//! the nested timing tree from it and the reader flattens files back into it. + +use serde::{Deserialize, Serialize}; + +use crate::ShapeId; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Transition { + /// Effect id: `none`, `fade`, `push`, `wipe`, `split`, `reveal`, `cut`, `randomBar`, `shape`, + /// `uncover`, `cover`, `flash`, `morph`, `dissolve`, `checker`, `blinds`, `clock`, `zoom`… (see + /// [`TRANSITIONS`]). + pub kind: String, + /// Effect option, e.g. direction `l`, `r`, `u`, `d`, `horz`, `vert`, `in`, `out`. + pub option: String, + pub duration_ms: u32, + pub advance_on_click: bool, + pub advance_after_ms: Option, + /// Embedded sound media. + pub sound: Option, + /// Unmodelled transition XML kept for round-trip. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +impl Default for Transition { + fn default() -> Self { + Transition { + kind: "none".into(), + option: String::new(), + duration_ms: 1000, + advance_on_click: true, + advance_after_ms: None, + sound: None, + raw: None, + } + } +} + +/// (id, label, category, default duration ms, effect options) +pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ + ("none", "None", "Subtle", 0, &[]), + ("morph", "Morph", "Subtle", 2000, &["objects", "words", "characters"]), + ("fade", "Fade", "Subtle", 700, &["smoothly", "throughBlack"]), + ("push", "Push", "Subtle", 1000, &["u", "d", "l", "r"]), + ("wipe", "Wipe", "Subtle", 1000, &["r", "l", "u", "d", "ru", "lu", "rd", "ld"]), + ("split", "Split", "Subtle", 1500, &["vertOut", "vertIn", "horzOut", "horzIn"]), + ("reveal", "Reveal", "Subtle", 2000, &["smoothlyLeft", "smoothlyRight", "blackLeft", "blackRight"]), + ("cut", "Cut", "Subtle", 0, &["", "throughBlack"]), + ("randomBar", "Random Bars", "Subtle", 1000, &["vert", "horz"]), + ("shape", "Shape", "Subtle", 2000, &["circle", "diamond", "plus", "in", "out"]), + ("uncover", "Uncover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]), + ("cover", "Cover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]), + ("flash", "Flash", "Subtle", 1000, &[]), + ("fallOver", "Fall Over", "Exciting", 2000, &["l", "r"]), + ("drape", "Drape", "Exciting", 2000, &["l", "r"]), + ("curtains", "Curtains", "Exciting", 2000, &[]), + ("wind", "Wind", "Exciting", 2000, &["l", "r"]), + ("prestige", "Prestige", "Exciting", 2000, &[]), + ("fracture", "Fracture", "Exciting", 2000, &[]), + ("crush", "Crush", "Exciting", 2000, &[]), + ("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]), + ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r"]), + ("airplane", "Airplane", "Exciting", 2000, &["l", "r"]), + ("origami", "Origami", "Exciting", 2000, &["l", "r"]), + ("dissolve", "Dissolve", "Exciting", 1200, &[]), + ("checker", "Checkerboard", "Exciting", 1500, &["horz", "vert"]), + ("blinds", "Blinds", "Exciting", 1600, &["vert", "horz"]), + ("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]), + ("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]), + ("honeycomb", "Honeycomb", "Exciting", 3000, &[]), + ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d"]), + ("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]), + ("shred", "Shred", "Exciting", 2000, &["strips", "particles"]), + ("switch", "Switch", "Exciting", 1250, &["l", "r"]), + ("flip", "Flip", "Exciting", 1250, &["l", "r"]), + ("gallery", "Gallery", "Exciting", 1500, &["l", "r"]), + ("cube", "Cube", "Exciting", 1250, &["l", "r", "u", "d"]), + ("doors", "Doors", "Exciting", 1400, &["vert", "horz"]), + ("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]), + ("comb", "Comb", "Exciting", 1000, &["horz", "vert"]), + ("zoom", "Zoom", "Exciting", 1000, &["in", "out"]), + ("random", "Random", "Exciting", 2000, &[]), + ("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]), + ("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]), + ("conveyor", "Conveyor", "Dynamic Content", 2000, &["l", "r"]), + ("rotate", "Rotate", "Dynamic Content", 2000, &["l", "r", "u", "d"]), + ("window", "Window", "Dynamic Content", 1250, &["vert", "horz"]), + ("orbit", "Orbit", "Dynamic Content", 2000, &["l", "r", "u", "d"]), + ("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]), +]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnimClass { + #[default] + Entrance, + Emphasis, + Exit, + Path, + Media, +} + +impl AnimClass { + pub fn xml(self) -> &'static str { + match self { + AnimClass::Entrance => "entr", + AnimClass::Emphasis => "emph", + AnimClass::Exit => "exit", + AnimClass::Path => "path", + AnimClass::Media => "mediacall", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "emph" => AnimClass::Emphasis, + "exit" => AnimClass::Exit, + "path" => AnimClass::Path, + "mediacall" => AnimClass::Media, + _ => AnimClass::Entrance, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnimStart { + #[default] + OnClick, + WithPrevious, + AfterPrevious, +} + +/// Text build: as one object, all at once by paragraph, or by paragraph level. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextBuild { + #[default] + AsOne, + ByParagraph, + ByWord, + ByLetter, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Animation { + pub shape: ShapeId, + pub class: AnimClass, + /// Effect id from [`ANIMATIONS`] (e.g. `fade`, `fly`, `zoom`, `spin`, `pulse`, `lines`…). + pub effect: String, + /// Effect option (direction etc.). + pub option: String, + pub start: AnimStart, + pub duration_ms: u32, + pub delay_ms: u32, + /// Repeat count (0/1 = once); `u32::MAX` = until next click. + pub repeat: u32, + pub rewind: bool, + pub text_build: TextBuild, + /// Paragraph index when the effect animates one paragraph of a text body. + pub paragraph: Option, + /// Motion path in slide-relative units (SVG-like `M 0 0 L 0.25 0 E`). + pub path: Option, + /// Triggered by a click on another shape. + pub trigger: Option, + /// Emphasis amount: spin degrees, grow/shrink scale (1.5 = 150%), transparency… + pub amount: Option, + /// Emphasis colour (fill/line/font colour change). + pub color: Option, + /// PowerPoint preset id/subtype read from or written to files. + pub preset_id: Option, + pub preset_subtype: Option, + /// After animation: dim to colour, hide, hide on next click. + pub after: Option, + pub sound: Option, + pub smooth_start: f64, + pub smooth_end: f64, + pub bounce_end: f64, + pub auto_reverse: bool, +} + +impl Default for Animation { + fn default() -> Self { + Animation { + shape: ShapeId(0), + class: AnimClass::Entrance, + effect: "fade".into(), + option: String::new(), + start: AnimStart::OnClick, + duration_ms: 500, + delay_ms: 0, + repeat: 1, + rewind: false, + text_build: TextBuild::AsOne, + paragraph: None, + path: None, + trigger: None, + amount: None, + color: None, + preset_id: None, + preset_subtype: None, + after: None, + sound: None, + smooth_start: 0.0, + smooth_end: 0.0, + bounce_end: 0.0, + auto_reverse: false, + } + } +} + +/// (id, label, class, PowerPoint preset id, default duration ms, options) +pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[ + ("appear", "Appear", AnimClass::Entrance, 1, 0, &[]), + ("fade", "Fade", AnimClass::Entrance, 10, 500, &[]), + ("fly", "Fly In", AnimClass::Entrance, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]), + ("float", "Float In", AnimClass::Entrance, 42, 1000, &["u", "d"]), + ("split", "Split", AnimClass::Entrance, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]), + ("wipe", "Wipe", AnimClass::Entrance, 22, 500, &["b", "l", "t", "r"]), + ("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]), + ("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]), + ("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]), + ("grow", "Grow & Turn", AnimClass::Entrance, 31, 500, &[]), + ("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]), + ("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]), + ("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]), + ("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]), + ("box", "Box", AnimClass::Entrance, 4, 500, &["in", "out"]), + ("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]), + ("circle", "Circle", AnimClass::Entrance, 6, 500, &["in", "out"]), + ("diamond", "Diamond", AnimClass::Entrance, 8, 500, &["in", "out"]), + ("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]), + ("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]), + ("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]), + ("expand", "Expand", AnimClass::Entrance, 55, 500, &[]), + ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]), + ("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]), + ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]), + ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]), + ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]), + ("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]), + ("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]), + ("desaturate", "Desaturate", AnimClass::Emphasis, 35, 500, &[]), + ("darken", "Darken", AnimClass::Emphasis, 25, 500, &[]), + ("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]), + ("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]), + ("objectColor", "Object Color", AnimClass::Emphasis, 19, 2000, &[]), + ("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]), + ("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]), + ("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]), + ("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]), + ("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]), + ("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 500, &[]), + ("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]), + ("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]), + ("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]), + ("flyOut", "Fly Out", AnimClass::Exit, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]), + ("floatOut", "Float Out", AnimClass::Exit, 42, 1000, &["u", "d"]), + ("splitOut", "Split", AnimClass::Exit, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]), + ("wipeOut", "Wipe", AnimClass::Exit, 22, 500, &["b", "l", "t", "r"]), + ("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]), + ("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]), + ("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]), + ("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 500, &[]), + ("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]), + ("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]), + ("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]), + ("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]), + ("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]), + ("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]), + ("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]), + ( + "shapes", + "Shapes", + AnimClass::Path, + 1, + 2000, + &["circle", "diamond", "hexagon", "triangle", "square", "trapezoid", "octagon", "parallelogram", "pentagon"], + ), + ("loops", "Loops", AnimClass::Path, 61, 2000, &[]), + ("customPath", "Custom Path", AnimClass::Path, 0, 2000, &[]), + ("play", "Play", AnimClass::Media, 1, 0, &[]), + ("pause", "Pause", AnimClass::Media, 2, 0, &[]), + ("stop", "Stop", AnimClass::Media, 3, 0, &[]), +]; + +pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str])> { + ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) +} diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs new file mode 100644 index 0000000..5ba72fc --- /dev/null +++ b/crates/model/src/chart.rs @@ -0,0 +1,290 @@ +//! Charts: an embedded data grid plus type and element options. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChartType { + #[default] + Column, + StackedColumn, + PercentColumn, + Bar, + StackedBar, + PercentBar, + Line, + LineMarkers, + StackedLine, + Area, + StackedArea, + Pie, + Doughnut, + Scatter, + Bubble, + Radar, + FilledRadar, + Stock, + Surface, + Histogram, + Pareto, + BoxWhisker, + Waterfall, + Funnel, + Treemap, + Sunburst, + Combo, +} + +impl ChartType { + pub const MAIN: [ChartType; 12] = [ + ChartType::Column, + ChartType::Bar, + ChartType::Line, + ChartType::Area, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Radar, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Treemap, + ]; + pub fn label(self) -> &'static str { + match self { + ChartType::Column => "Clustered Column", + ChartType::StackedColumn => "Stacked Column", + ChartType::PercentColumn => "100% Stacked Column", + ChartType::Bar => "Clustered Bar", + ChartType::StackedBar => "Stacked Bar", + ChartType::PercentBar => "100% Stacked Bar", + ChartType::Line => "Line", + ChartType::LineMarkers => "Line with Markers", + ChartType::StackedLine => "Stacked Line", + ChartType::Area => "Area", + ChartType::StackedArea => "Stacked Area", + ChartType::Pie => "Pie", + ChartType::Doughnut => "Doughnut", + ChartType::Scatter => "Scatter", + ChartType::Bubble => "Bubble", + ChartType::Radar => "Radar", + ChartType::FilledRadar => "Filled Radar", + ChartType::Stock => "Stock", + ChartType::Surface => "Surface", + ChartType::Histogram => "Histogram", + ChartType::Pareto => "Pareto", + ChartType::BoxWhisker => "Box and Whisker", + ChartType::Waterfall => "Waterfall", + ChartType::Funnel => "Funnel", + ChartType::Treemap => "Treemap", + ChartType::Sunburst => "Sunburst", + ChartType::Combo => "Combo", + } + } + pub fn is_bar_like(self) -> bool { + matches!( + self, + ChartType::Column + | ChartType::StackedColumn + | ChartType::PercentColumn + | ChartType::Bar + | ChartType::StackedBar + | ChartType::PercentBar + | ChartType::Histogram + | ChartType::Pareto + | ChartType::Waterfall + ) + } + pub fn horizontal(self) -> bool { + matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) + } + pub fn stacked(self) -> bool { + matches!( + self, + ChartType::StackedColumn + | ChartType::StackedBar + | ChartType::StackedLine + | ChartType::StackedArea + | ChartType::PercentColumn + | ChartType::PercentBar + ) + } + pub fn percent(self) -> bool { + matches!(self, ChartType::PercentColumn | ChartType::PercentBar) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Series { + pub name: String, + pub values: Vec>, + /// X values for scatter/bubble. + pub x_values: Vec>, + pub sizes: Vec>, + pub fill: Option, + pub line: Option, + /// Per-point fill overrides (pie slices). + pub point_fills: Vec<(usize, Fill)>, + pub data_labels: Option, + pub smooth: bool, + pub marker: Option, + /// Combo charts: this series' own type. + pub kind: Option, + pub secondary_axis: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Chart { + pub kind: ChartType, + pub title: Option, + pub categories: Vec, + pub series: Vec, + pub legend: Option, + pub data_labels: bool, + pub gridlines: bool, + pub axis_titles: (Option, Option), + /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). + pub style: u32, + pub palette: String, + pub plot_fill: Option, + pub chart_fill: Option, + pub text_color: Option, + pub hole_size: f64, + pub gap_width: f64, + pub overlap: f64, + pub vary_colors: bool, + pub y_min: Option, + pub y_max: Option, + /// Original chart XML part kept for round-trip of unmodelled options. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +impl Default for Chart { + fn default() -> Self { + Chart { + kind: ChartType::Column, + title: Some("Chart Title".into()), + categories: vec![], + series: vec![], + legend: Some("b".into()), + data_labels: false, + gridlines: true, + axis_titles: (None, None), + style: 2, + palette: "colorful1".into(), + plot_fill: None, + chart_fill: None, + text_color: None, + hole_size: 0.5, + gap_width: 2.19, + overlap: -0.27, + vary_colors: false, + y_min: None, + y_max: None, + raw: None, + } + } +} + +impl Chart { + /// The sample data a new chart starts with (our own numbers). + pub fn sample(kind: ChartType) -> Self { + let cats = ["Q1", "Q2", "Q3", "Q4"]; + let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; + let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + let n_series = + if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: data.iter().map(|row| row.get(s).copied()).collect(), + x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, + ..Default::default() + }); + } + c.vary_colors = n_series == 1; + c + } + /// Value range across series (stacked sums for stacked types). + pub fn value_range(&self) -> (f64, f64) { + let mut lo: f64 = 0.0; + let mut hi: f64 = 0.0; + let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + if self.kind.stacked() { + for i in 0..n { + let (mut p, mut m) = (0.0, 0.0); + for s in &self.series { + let v = s.values.get(i).copied().flatten().unwrap_or(0.0); + if v >= 0.0 { p += v } else { m += v } + } + hi = hi.max(p); + lo = lo.min(m); + } + } else { + for s in &self.series { + for v in s.values.iter().flatten() { + if v.is_finite() { + hi = hi.max(*v); + lo = lo.min(*v); + } + } + } + } + if self.kind.percent() { + return (0.0, 1.0); + } + (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) + } +} + +/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. +pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; + let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; + let raw = span / 5.0; + let mag = 10f64.powf(raw.log10().floor()); + let norm = raw / mag; + let step = if norm <= 1.0 { + 1.0 + } else if norm <= 2.0 { + 2.0 + } else if norm <= 2.5 { + 2.5 + } else if norm <= 5.0 { + 5.0 + } else { + 10.0 + } * mag; + let min = (lo / step).floor() * step; + let max = (hi / step).ceil() * step; + let max = if (max - min).abs() < 1e-12 { min + step } else { max }; + (min, max, step) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nice_scale_covers() { + let (a, b, s) = nice_scale(0.0, 5.1); + assert!(a <= 0.0 && b >= 5.1 && s > 0.0); + let (a, b, _) = nice_scale(f64::NAN, 3.0); + assert!(a.is_finite() && b.is_finite()); + let (a, b, _) = nice_scale(7.0, 7.0); + assert!(b > a); + } + + #[test] + fn sample_ranges() { + let c = Chart::sample(ChartType::StackedColumn); + let (_, hi) = c.value_range(); + assert!(hi > 10.0); + assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); + } +} diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs new file mode 100644 index 0000000..09e0e3a --- /dev/null +++ b/crates/model/src/defaults.rs @@ -0,0 +1,550 @@ +//! A new presentation: one master with the standard set of layouts, master text styles, and a +//! first title slide. Positions and sizes are our own (16:9 at 960 × 540 pt, 4:3 scaled). + +use std::sync::Arc; + +use slidecraft_color::SchemeSlot; +use slidecraft_geom::{Size, Xfrm}; + +use crate::style::ColorRef; +use crate::text::{Anchor, AutoFit, BodyProps, Bullet, LevelStyle, ListStyle, ParaProps, RunProps, Spacing, TextBody, TextDir}; +use crate::theme::Theme; +use crate::{ + Background, Fill, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, PhType, Placeholder, Presentation, Shape, ShapeId, ShapeKind, + Slide, SlideId, +}; + +pub const WIDE: Size = Size::new(960.0, 540.0); +pub const STANDARD: Size = Size::new(720.0, 540.0); + +/// Slide size presets for Design ▸ Slide Size (label, width, height in points). +pub const SLIDE_SIZES: &[(&str, f64, f64)] = &[ + ("Widescreen (16:9)", 960.0, 540.0), + ("Standard (4:3)", 720.0, 540.0), + ("On-screen Show (16:10)", 720.0, 450.0), + ("Letter Paper (8.5x11 in)", 720.0, 540.0), + ("Ledger Paper (11x17 in)", 960.0, 720.0), + ("A3 Paper (297x420 mm)", 1008.0, 756.0), + ("A4 Paper (210x297 mm)", 780.0, 540.0), + ("B4 (ISO) Paper (250x353 mm)", 852.0, 639.0), + ("B5 (ISO) Paper (176x250 mm)", 598.0, 448.5), + ("35mm Slides", 810.0, 540.0), + ("Overhead", 720.0, 540.0), + ("Banner", 576.0, 72.0), + ("Widescreen (13.333x7.5 in)", 960.0, 540.0), + ("Square (1:1)", 540.0, 540.0), + ("Portrait (9:16)", 540.0, 960.0), +]; + +fn ph(kind: PhType, idx: u32) -> Placeholder { + Placeholder { kind, idx, ..Default::default() } +} + +fn body_with(anchor: Option, autofit: Option) -> TextBody { + TextBody { body: BodyProps { anchor, autofit, ..Default::default() }, ..Default::default() } +} + +struct B { + next: u32, +} + +impl B { + fn shape(&mut self, name: &str, kind: PhType, idx: u32, x: f64, y: f64, w: f64, h: f64) -> Shape { + self.next += 1; + Shape { + id: ShapeId(self.next), + name: name.into(), + xfrm: Some(Xfrm::new(x, y, w, h)), + ph: Some(ph(kind, idx)), + text: Some(TextBody::default()), + ..Default::default() + } + } +} + +fn level(size: f64, margin: f64, indent: f64, before: f64) -> LevelStyle { + LevelStyle { + para: ParaProps { + align: Some(crate::text::Align::Left), + margin_left: Some(margin), + indent: Some(indent), + line_spacing: Some(Spacing::Pct(0.9)), + space_before: Some(Spacing::Pts(before)), + bullet: Some(Bullet::Char { char: "•".into() }), + bullet_font: Some("Arial".into()), + ..Default::default() + }, + run: RunProps { + size: Some(size), + font: Some("+mn-lt".into()), + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), + kern: Some(12.0), + ..Default::default() + }, + } +} + +/// Master text styles: title, body (bulleted levels) and other. +pub fn master_styles() -> (ListStyle, ListStyle, ListStyle) { + let mut title = ListStyle::default(); + title.set( + 0, + LevelStyle { + para: ParaProps { + align: Some(crate::text::Align::Left), + line_spacing: Some(Spacing::Pct(0.9)), + space_before: Some(Spacing::Pts(0.0)), + bullet: Some(Bullet::None), + ..Default::default() + }, + run: RunProps { + size: Some(44.0), + font: Some("+mj-lt".into()), + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), + kern: Some(12.0), + ..Default::default() + }, + }, + ); + let mut body = ListStyle::default(); + let sizes = [28.0, 24.0, 20.0, 18.0, 18.0, 18.0, 18.0, 18.0, 18.0]; + for (i, sz) in sizes.iter().enumerate() { + let margin = 18.0 + 36.0 * i as f64; + body.set(i as u8, level(*sz, margin, -18.0, if i == 0 { 10.0 } else { 5.0 })); + } + let mut other = ListStyle::default(); + for i in 0..9u8 { + other.set( + i, + LevelStyle { + para: ParaProps { margin_left: Some(36.0 * i as f64), align: Some(crate::text::Align::Left), ..Default::default() }, + run: RunProps { + size: Some(18.0), + font: Some("+mn-lt".into()), + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), + kern: Some(12.0), + ..Default::default() + }, + }, + ); + } + (title, body, other) +} + +/// Presentation default text style for free shapes and text boxes. +pub fn default_text_style() -> ListStyle { + let mut s = ListStyle::default(); + for i in 0..9u8 { + s.set( + i, + LevelStyle { + para: ParaProps { margin_left: Some(36.0 * i as f64), align: Some(crate::text::Align::Left), ..Default::default() }, + run: RunProps { + size: Some(18.0), + font: Some("+mn-lt".into()), + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))), + kern: Some(12.0), + ..Default::default() + }, + }, + ); + } + s +} + +/// The master and its eleven standard layouts for a slide size, with ids starting at `first_id`. +/// Returns the master and the next free id. +pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { + let (w, h) = (size.width, size.height); + let sx = w / 960.0; + let sy = h / 540.0; + let r = |x: f64, y: f64, ww: f64, hh: f64| (x * sx, y * sy, ww * sx, hh * sy); + let mut b = B { next: first_id }; + let footers = |b: &mut B| { + let (x1, y1, w1, h1) = r(60.0, 500.0, 216.0, 28.0); + let (x2, _, w2, _) = r(318.0, 500.0, 324.0, 28.0); + let (x3, _, w3, _) = r(684.0, 500.0, 216.0, 28.0); + let mut d = b.shape("Date Placeholder", PhType::Date, 10, x1, y1, w1, h1); + let mut f = b.shape("Footer Placeholder", PhType::Footer, 11, x2, y1, w2, h1); + let mut n = b.shape("Slide Number Placeholder", PhType::SlideNum, 12, x3, y1, w3, h1); + let small = |align| { + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { align: Some(align), ..Default::default() }, + run: RunProps { + size: Some(12.0), + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1).with(slidecraft_color::ColorTransform::Tint(75000)))), + ..Default::default() + }, + }, + ); + ls + }; + for (s, a) in [(&mut d, crate::text::Align::Left), (&mut f, crate::text::Align::Center), (&mut n, crate::text::Align::Right)] { + if let Some(t) = s.text.as_mut() { + t.body.anchor = Some(Anchor::Middle); + t.list_style = small(a); + } + } + if let Some(t) = n.text.as_mut() { + t.paragraphs = vec![crate::text::Paragraph { + runs: vec![crate::text::Run { + text: "‹#›".into(), + props: RunProps::default(), + kind: crate::text::RunKind::Field { field: "slidenum".into() }, + }], + ..Default::default() + }]; + } + vec![d, f, n] + }; + let title_box = |b: &mut B| { + let (x, y, ww, hh) = r(60.0, 29.0, 840.0, 104.0); + let mut s = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }))); + s + }; + let body_box = |b: &mut B, name: &str, idx: u32, x: f64, y: f64, ww: f64, hh: f64| { + let (x, y, ww, hh) = r(x, y, ww, hh); + let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh); + s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }))); + s + }; + + // Master placeholders. + let mut mshapes = vec![title_box(&mut b), body_box(&mut b, "Text Placeholder 2", 1, 60.0, 144.0, 840.0, 343.0)]; + mshapes.extend(footers(&mut b)); + for s in &mut mshapes { + let kind = s.ph_type(); + if let Some(t) = s.text.as_mut() + && t.paragraphs.is_empty() + { + let prompt = match kind { + Some(PhType::Title) => "Click to edit Master title style", + Some(PhType::Body) => "Click to edit Master text styles", + _ => "", + }; + if !prompt.is_empty() { + t.paragraphs = vec![crate::text::Paragraph::new(prompt)]; + } + } + } + + let mut layouts = vec![]; + let mut lay = |b: &mut B, name: &str, kind: LayoutType, shapes: Vec| { + b.next += 1; + layouts.push(Layout { + id: LayoutId(b.next), + name: name.into(), + kind, + shapes, + background: None, + show_master_shapes: true, + preserve: true, + raw_ext: None, + }); + }; + + // Title Slide + { + let (x, y, ww, hh) = r(120.0, 88.0, 720.0, 188.0); + let mut t = b.shape("Title 1", PhType::CtrTitle, 0, x, y, ww, hh); + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { align: Some(crate::text::Align::Center), ..Default::default() }, + run: RunProps { size: Some(60.0), ..Default::default() }, + }, + ); + t.text = Some(TextBody { + body: BodyProps { + anchor: Some(Anchor::Bottom), + autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }), + ..Default::default() + }, + list_style: ls, + paragraphs: vec![], + }); + let (x, y, ww, hh) = r(120.0, 284.0, 720.0, 130.0); + let mut st = b.shape("Subtitle 2", PhType::SubTitle, 1, x, y, ww, hh); + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { + align: Some(crate::text::Align::Center), + margin_left: Some(0.0), + indent: Some(0.0), + bullet: Some(Bullet::None), + ..Default::default() + }, + run: RunProps { size: Some(24.0), ..Default::default() }, + }, + ); + st.text = Some(TextBody { + body: BodyProps { + anchor: Some(Anchor::Top), + autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }), + ..Default::default() + }, + list_style: ls, + paragraphs: vec![], + }); + let mut v = vec![t, st]; + v.extend(footers(&mut b)); + lay(&mut b, "Title Slide", LayoutType::Title, v); + } + // Title and Content + { + let t = title_box(&mut b); + let c = body_with_ph(body_box(&mut b, "Content Placeholder 2", 1, 60.0, 144.0, 840.0, 343.0), PhType::Obj); + let mut v = vec![t, c]; + v.extend(footers(&mut b)); + lay(&mut b, "Title and Content", LayoutType::TitleAndContent, v); + } + // Section Header + { + let (x, y, ww, hh) = r(65.0, 135.0, 830.0, 225.0); + let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut ls = ListStyle::default(); + ls.set(0, LevelStyle { run: RunProps { size: Some(60.0), ..Default::default() }, ..Default::default() }); + t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); + let mut s = body_box(&mut b, "Text Placeholder 2", 1, 65.0, 362.0, 830.0, 118.0); + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { margin_left: Some(0.0), indent: Some(0.0), bullet: Some(Bullet::None), ..Default::default() }, + run: RunProps { + size: Some(24.0), + fill: Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1).with(slidecraft_color::ColorTransform::Tint(82000)))), + ..Default::default() + }, + }, + ); + if let Some(tb) = s.text.as_mut() { + tb.list_style = ls; + } + let mut v = vec![t, s]; + v.extend(footers(&mut b)); + lay(&mut b, "Section Header", LayoutType::SectionHeader, v); + } + // Two Content + { + let t = title_box(&mut b); + let l = body_with_ph(body_box(&mut b, "Content Placeholder 2", 1, 60.0, 144.0, 412.0, 343.0), PhType::Obj); + let rr = body_with_ph(body_box(&mut b, "Content Placeholder 3", 2, 488.0, 144.0, 412.0, 343.0), PhType::Obj); + let mut v = vec![t, l, rr]; + v.extend(footers(&mut b)); + lay(&mut b, "Two Content", LayoutType::TwoContent, v); + } + // Comparison + { + let t = title_box(&mut b); + let head = |b: &mut B, name: &str, idx: u32, x: f64| { + let (x, y, ww, hh) = r(x, 133.0, 412.0, 62.0); + let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh); + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { margin_left: Some(0.0), indent: Some(0.0), bullet: Some(Bullet::None), ..Default::default() }, + run: RunProps { size: Some(24.0), bold: Some(true), ..Default::default() }, + }, + ); + s.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); + s + }; + let h1 = head(&mut b, "Text Placeholder 2", 1, 60.0); + let c1 = body_with_ph(body_box(&mut b, "Content Placeholder 3", 2, 60.0, 198.0, 412.0, 289.0), PhType::Obj); + let h2 = head(&mut b, "Text Placeholder 4", 3, 488.0); + let c2 = body_with_ph(body_box(&mut b, "Content Placeholder 5", 4, 488.0, 198.0, 412.0, 289.0), PhType::Obj); + let mut v = vec![t, h1, c1, h2, c2]; + v.extend(footers(&mut b)); + lay(&mut b, "Comparison", LayoutType::Comparison, v); + } + // Title Only + { + let mut v = vec![title_box(&mut b)]; + v.extend(footers(&mut b)); + lay(&mut b, "Title Only", LayoutType::TitleOnly, v); + } + // Blank + { + let v = footers(&mut b); + lay(&mut b, "Blank", LayoutType::Blank, v); + } + // Content with Caption + { + let (x, y, ww, hh) = r(60.0, 36.0, 322.0, 126.0); + let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut ls = ListStyle::default(); + ls.set(0, LevelStyle { run: RunProps { size: Some(32.0), ..Default::default() }, ..Default::default() }); + t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); + let c = body_with_ph(body_box(&mut b, "Content Placeholder 2", 1, 412.0, 78.0, 488.0, 384.0), PhType::Obj); + let mut cap = body_box(&mut b, "Text Placeholder 3", 2, 60.0, 162.0, 322.0, 300.0); + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { margin_left: Some(0.0), indent: Some(0.0), bullet: Some(Bullet::None), ..Default::default() }, + run: RunProps { size: Some(16.0), ..Default::default() }, + }, + ); + if let Some(tb) = cap.text.as_mut() { + tb.list_style = ls; + } + let mut v = vec![t, c, cap]; + v.extend(footers(&mut b)); + lay(&mut b, "Content with Caption", LayoutType::ContentWithCaption, v); + } + // Picture with Caption + { + let (x, y, ww, hh) = r(60.0, 36.0, 322.0, 126.0); + let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh); + let mut ls = ListStyle::default(); + ls.set(0, LevelStyle { run: RunProps { size: Some(32.0), ..Default::default() }, ..Default::default() }); + t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] }); + let (x, y, ww, hh) = r(412.0, 78.0, 488.0, 384.0); + let p = b.shape("Picture Placeholder 2", PhType::Picture, 1, x, y, ww, hh); + let mut cap = body_box(&mut b, "Text Placeholder 3", 2, 60.0, 162.0, 322.0, 300.0); + let mut ls = ListStyle::default(); + ls.set( + 0, + LevelStyle { + para: ParaProps { margin_left: Some(0.0), indent: Some(0.0), bullet: Some(Bullet::None), ..Default::default() }, + run: RunProps { size: Some(16.0), ..Default::default() }, + }, + ); + if let Some(tb) = cap.text.as_mut() { + tb.list_style = ls; + } + let mut v = vec![t, p, cap]; + v.extend(footers(&mut b)); + lay(&mut b, "Picture with Caption", LayoutType::PictureWithCaption, v); + } + // Title and Vertical Text + { + let t = title_box(&mut b); + let mut c = body_box(&mut b, "Vertical Text Placeholder 2", 1, 60.0, 144.0, 840.0, 343.0); + if let Some(tb) = c.text.as_mut() { + tb.body.vert = Some(TextDir::EaVertical); + } + if let Some(p) = c.ph.as_mut() { + p.vertical = true; + } + let mut v = vec![t, c]; + v.extend(footers(&mut b)); + lay(&mut b, "Title and Vertical Text", LayoutType::TitleAndVerticalText, v); + } + // Vertical Title and Text + { + let (x, y, ww, hh) = r(687.0, 29.0, 213.0, 458.0); + let mut t = b.shape("Vertical Title 1", PhType::Title, 0, x, y, ww, hh); + t.text = Some(TextBody { body: BodyProps { vert: Some(TextDir::EaVertical), ..Default::default() }, ..Default::default() }); + if let Some(p) = t.ph.as_mut() { + p.vertical = true; + } + let mut c = body_box(&mut b, "Vertical Text Placeholder 2", 1, 60.0, 29.0, 612.0, 458.0); + if let Some(tb) = c.text.as_mut() { + tb.body.vert = Some(TextDir::EaVertical); + } + if let Some(p) = c.ph.as_mut() { + p.vertical = true; + } + let mut v = vec![t, c]; + v.extend(footers(&mut b)); + lay(&mut b, "Vertical Title and Text", LayoutType::VerticalTitleAndText, v); + } + + let (title_style, body_style, other_style) = master_styles(); + b.next += 1; + let master = Master { + id: MasterId(b.next), + name: theme.name.clone(), + theme, + shapes: mshapes, + background: Some(Background::Ref { idx: 1001, color: ColorRef::scheme(SchemeSlot::Bg1) }), + title_style, + body_style, + other_style, + layouts, + color_map: vec![], + preserve: false, + raw_ext: None, + }; + (master, b.next + 1) +} + +fn body_with_ph(mut s: Shape, kind: PhType) -> Shape { + if let Some(p) = s.ph.as_mut() { + p.kind = kind; + } + s +} + +/// A new presentation with one title slide. +pub fn new_presentation(theme: Option) -> Presentation { + blank_presentation(WIDE, theme.unwrap_or_default(), true) +} + +pub fn blank_presentation(size: Size, theme: Theme, title_slide: bool) -> Presentation { + let (master, next) = build_master(size, theme, 1); + let first_layout = master.layouts.first().map(|l| l.id).unwrap_or_default(); + let mut p = Presentation { + slide_size: size, + notes_size: Size::new(540.0, 720.0), + slides: vec![], + masters: vec![Arc::new(master)], + notes_master: None, + handout_master: None, + sections: vec![], + custom_shows: vec![], + show: Default::default(), + header_footer: HeaderFooter::default(), + props: Default::default(), + default_text_style: default_text_style(), + media: vec![], + first_slide_number: 1, + next_id: next.max(256), + embedded_fonts: vec![], + raw_ext: None, + }; + if title_slide { + let s = new_slide(&mut p, first_layout); + p.slides.push(Arc::new(s)); + } + p +} + +/// A slide for `layout` with its placeholders instantiated (empty, inheriting position). +pub fn new_slide(p: &mut Presentation, layout: LayoutId) -> Slide { + let id = SlideId(p.alloc_id()); + let mut shapes = vec![]; + let lay_shapes: Vec = p.layout(layout).map(|(_, l)| l.shapes.clone()).unwrap_or_default(); + for ls in lay_shapes { + let Some(ph) = ls.ph.clone() else { continue }; + if ph.kind.is_footer_kind() { + continue; + } + let sid = ShapeId(p.alloc_id()); + shapes.push(Shape { + id: sid, + name: ls.name.clone(), + xfrm: None, + kind: ShapeKind::Shape, + ph: Some(ph), + text: Some(TextBody::default()), + ..Default::default() + }); + } + Slide { id, layout, shapes, ..Default::default() } +} + +/// Layout for a kind in the first master. +pub fn layout_of_kind(p: &Presentation, kind: LayoutType) -> Option { + p.masters.first().and_then(|m| m.layouts.iter().find(|l| l.kind == kind)).map(|l| l.id) +} diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs new file mode 100644 index 0000000..dcdc508 --- /dev/null +++ b/crates/model/src/edit.rs @@ -0,0 +1,475 @@ +//! Text editing on a [`TextBody`]: positions are (paragraph, character offset). Inserting text +//! with `\n` splits paragraphs; formatting a range splits runs at its ends. + +use crate::text::{Paragraph, Run, RunKind, RunProps, TextBody}; + +/// (paragraph, char offset in the paragraph). +pub type Pos = (usize, usize); + +fn clamp(body: &TextBody, p: Pos) -> Pos { + if body.paragraphs.is_empty() { + return (0, 0); + } + let pi = p.0.min(body.paragraphs.len() - 1); + let n = body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0); + (pi, p.1.min(n)) +} + +fn order(a: Pos, b: Pos) -> (Pos, Pos) { + if a <= b { (a, b) } else { (b, a) } +} + +/// Split the paragraph's runs so a run boundary falls at char `at`; returns the index of the +/// first run starting at or after `at`. +fn split_at(p: &mut Paragraph, at: usize) -> usize { + let mut pos = 0; + let mut i = 0; + while i < p.runs.len() { + let len = p.runs.get(i).map(Run::char_len).unwrap_or(0); + if pos == at { + return i; + } + if at < pos + len { + let Some(r) = p.runs.get(i) else { return i }; + if r.kind != RunKind::Text { + return i + 1; + } + let k = at - pos; + let byte = r.text.char_indices().nth(k).map(|(b, _)| b).unwrap_or(r.text.len()); + let (a, b) = r.text.split_at(byte); + let second = Run { text: b.to_string(), props: r.props.clone(), kind: RunKind::Text }; + let first = a.to_string(); + if let Some(r) = p.runs.get_mut(i) { + r.text = first; + } + p.runs.insert(i + 1, second); + return i + 1; + } + pos += len; + i += 1; + } + p.runs.len() +} + +/// The explicit run properties in effect at `pos` (the run before the caret, or the end mark). +pub fn props_at(body: &TextBody, pos: Pos) -> RunProps { + let (pi, ch) = clamp(body, pos); + let Some(p) = body.paragraphs.get(pi) else { return RunProps::default() }; + let mut acc = 0; + let mut last = None; + for r in &p.runs { + let len = r.char_len(); + if ch <= acc + len && ch > acc { + return r.props.clone(); + } + if acc + len >= ch && ch == 0 { + return r.props.clone(); + } + acc += len; + last = Some(&r.props); + } + last.cloned().unwrap_or_else(|| p.end_props.clone()) +} + +/// Insert `text` at `pos` (with `props`, else the props at `pos`). `\n` starts a new paragraph +/// (same level and paragraph props); `\u{b}` inserts a line break. Returns the caret after it. +pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option) -> Pos { + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::default()); + } + let (mut pi, mut ch) = clamp(body, pos); + let props = props.unwrap_or_else(|| props_at(body, (pi, ch))); + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut first = true; + for part in text.split('\n') { + if !first { + // Split paragraph pi at ch. + let Some(p) = body.paragraphs.get_mut(pi) else { break }; + let idx = split_at(p, ch); + let tail: Vec = p.runs.drain(idx..).collect(); + let newp = Paragraph { level: p.level, props: p.props.clone(), runs: tail, end_props: p.end_props.clone() }; + p.end_props = props.clone(); + body.paragraphs.insert(pi + 1, newp); + pi += 1; + ch = 0; + } + first = false; + for (k, seg) in part.split('\u{b}').enumerate() { + let Some(p) = body.paragraphs.get_mut(pi) else { break }; + if k > 0 { + let idx = split_at(p, ch); + p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break }); + ch += 1; + } + if seg.is_empty() { + continue; + } + let idx = split_at(p, ch); + p.runs.insert(idx, Run::with(seg, props.clone())); + ch += seg.chars().count(); + } + if let Some(p) = body.paragraphs.get_mut(pi) { + p.normalize(); + if p.runs.is_empty() { + p.end_props = props.clone(); + } + } + } + (pi, ch) +} + +/// Delete the range between `a` and `b`; returns the caret at the start. +pub fn delete(body: &mut TextBody, a: Pos, b: Pos) -> Pos { + if body.paragraphs.is_empty() { + return (0, 0); + } + let (s, e) = order(clamp(body, a), clamp(body, b)); + if s == e { + return s; + } + if s.0 == e.0 { + if let Some(p) = body.paragraphs.get_mut(s.0) { + let end_props = props_of_range(p, s.1); + let i0 = split_at(p, s.1); + let i1 = split_at(p, e.1); + if i1 > i0 { + p.runs.drain(i0..i1); + } + p.normalize(); + if p.runs.is_empty() { + p.end_props = end_props; + } + } + return s; + } + // Keep the head of s.0 and the tail of e.0, joined; drop paragraphs between. + let tail: Vec = match body.paragraphs.get_mut(e.0) { + Some(p) => { + let i = split_at(p, e.1); + p.runs.drain(i..).collect() + } + None => vec![], + }; + if let Some(p) = body.paragraphs.get_mut(s.0) { + let i = split_at(p, s.1); + p.runs.truncate(i); + p.runs.extend(tail); + p.normalize(); + } + if e.0 > s.0 && e.0 < body.paragraphs.len() { + body.paragraphs.drain(s.0 + 1..=e.0); + } + s +} + +fn props_of_range(p: &Paragraph, at: usize) -> RunProps { + let mut acc = 0; + for r in &p.runs { + let len = r.char_len(); + if at < acc + len || (at == acc + len && at > 0) { + return r.props.clone(); + } + acc += len; + } + p.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| p.end_props.clone()) +} + +/// Apply `f` to the run properties of every run in [a, b). An empty range changes the empty +/// paragraph's end mark (so typing there picks the formatting up). +pub fn format(body: &mut TextBody, a: Pos, b: Pos, f: &dyn Fn(&mut RunProps)) { + if body.paragraphs.is_empty() { + return; + } + let (s, e) = order(clamp(body, a), clamp(body, b)); + for pi in s.0..=e.0 { + let Some(p) = body.paragraphs.get_mut(pi) else { continue }; + let from = if pi == s.0 { s.1 } else { 0 }; + let to = if pi == e.0 { e.1 } else { p.char_len() }; + if p.runs.is_empty() || (from == to && s == e) { + f(&mut p.end_props); + continue; + } + let i0 = split_at(p, from); + let i1 = split_at(p, to); + for r in p.runs.iter_mut().take(i1).skip(i0) { + f(&mut r.props); + } + if to == p.char_len() { + f(&mut p.end_props); + } + p.normalize(); + } +} + +/// Apply `f` to every run in the body (and end marks). +pub fn format_all(body: &mut TextBody, f: &dyn Fn(&mut RunProps)) { + for p in &mut body.paragraphs { + for r in &mut p.runs { + f(&mut r.props); + } + f(&mut p.end_props); + p.normalize(); + } +} + +/// The text between two positions (`\n` between paragraphs). +pub fn text_range(body: &TextBody, a: Pos, b: Pos) -> String { + let (s, e) = order(clamp(body, a), clamp(body, b)); + let mut out = String::new(); + for pi in s.0..=e.0 { + let Some(p) = body.paragraphs.get(pi) else { continue }; + let t: Vec = p.text().chars().collect(); + let from = if pi == s.0 { s.1 } else { 0 }; + let to = if pi == e.0 { e.1 } else { t.len() }; + out.extend(t.get(from.min(t.len())..to.min(t.len())).unwrap_or(&[])); + if pi != e.0 { + out.push('\n'); + } + } + out +} + +/// A formatted copy of the range. +pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody { + let (s, e) = order(clamp(body, a), clamp(body, b)); + let mut out = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), paragraphs: vec![] }; + for pi in s.0..=e.0 { + let Some(p) = body.paragraphs.get(pi) else { continue }; + let mut p = p.clone(); + let to = if pi == e.0 { e.1 } else { p.char_len() }; + let i1 = split_at(&mut p, to); + p.runs.truncate(i1); + let from = if pi == s.0 { s.1 } else { 0 }; + let i0 = split_at(&mut p, from); + p.runs.drain(..i0); + out.paragraphs.push(p); + } + out +} + +/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one. +pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos { + let mut caret = clamp(body, pos); + for (k, sp) in src.paragraphs.iter().enumerate() { + if k > 0 { + caret = insert(body, caret, "\n", None); + if let Some(p) = body.paragraphs.get_mut(caret.0) { + p.level = sp.level; + p.props = sp.props.clone(); + } + } + for r in &sp.runs { + match r.kind { + RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())), + RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())), + _ => { + if let Some(p) = body.paragraphs.get_mut(caret.0) { + let idx = split_at(p, caret.1); + p.runs.insert(idx, r.clone()); + caret.1 += r.char_len(); + } + } + } + } + } + caret +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' || c == '\'' +} + +/// Word boundaries around `pos` (double-click selection). +pub fn word_at(body: &TextBody, pos: Pos) -> (Pos, Pos) { + let (pi, ch) = clamp(body, pos); + let t: Vec = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default(); + if t.is_empty() { + return ((pi, 0), (pi, 0)); + } + let at = ch.min(t.len().saturating_sub(1)); + let w = t.get(at).copied().is_some_and(is_word); + let mut s = at; + while s > 0 && t.get(s - 1).copied().is_some_and(|c| is_word(c) == w && !c.is_whitespace() || (w && is_word(c))) { + s -= 1; + } + let mut e = at; + while e < t.len() && t.get(e).copied().is_some_and(|c| is_word(c) == w && (w || !c.is_whitespace())) { + e += 1; + } + // Include trailing spaces after a word. + while w && e < t.len() && t.get(e) == Some(&' ') { + e += 1; + } + ((pi, s), (pi, e.max(s))) +} + +/// Next/previous word start (Option/Ctrl + arrows). +pub fn word_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { + let (pi, ch) = clamp(body, pos); + let t: Vec = body.paragraphs.get(pi).map(|p| p.text().chars().collect()).unwrap_or_default(); + if forward { + if ch >= t.len() { + return if pi + 1 < body.paragraphs.len() { (pi + 1, 0) } else { (pi, ch) }; + } + let mut i = ch; + while i < t.len() && t.get(i).copied().is_some_and(is_word) { + i += 1; + } + while i < t.len() && !t.get(i).copied().is_some_and(is_word) { + i += 1; + } + (pi, i) + } else { + if ch == 0 { + return if pi > 0 { (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) } else { (0, 0) }; + } + let mut i = ch; + while i > 0 && !t.get(i - 1).copied().is_some_and(is_word) { + i -= 1; + } + while i > 0 && t.get(i - 1).copied().is_some_and(is_word) { + i -= 1; + } + (pi, i) + } +} + +/// One character left/right across paragraph boundaries. +pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos { + let (pi, ch) = clamp(body, pos); + let n = body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0); + if forward { + if ch < n { + (pi, ch + 1) + } else if pi + 1 < body.paragraphs.len() { + (pi + 1, 0) + } else { + (pi, ch) + } + } else if ch > 0 { + (pi, ch - 1) + } else if pi > 0 { + (pi - 1, body.paragraphs.get(pi - 1).map(Paragraph::char_len).unwrap_or(0)) + } else { + (0, 0) + } +} + +/// End of the body. +pub fn end(body: &TextBody) -> Pos { + let pi = body.paragraphs.len().saturating_sub(1); + (pi, body.paragraphs.get(pi).map(Paragraph::char_len).unwrap_or(0)) +} + +/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`. +pub fn change_case(s: &str, mode: &str) -> String { + match mode { + "upper" => s.to_uppercase(), + "lower" => s.to_lowercase(), + "toggle" => s.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::() } else { c.to_uppercase().collect() }).collect(), + "title" => { + let mut out = String::new(); + let mut start = true; + for c in s.chars() { + if start && c.is_alphabetic() { + out.extend(c.to_uppercase()); + start = false; + } else { + out.extend(c.to_lowercase()); + } + if c.is_whitespace() { + start = true; + } + } + out + } + _ => { + // sentence + let mut out = String::new(); + let mut start = true; + for c in s.chars() { + if start && c.is_alphabetic() { + out.extend(c.to_uppercase()); + start = false; + } else { + out.extend(c.to_lowercase()); + } + if matches!(c, '.' | '!' | '?') { + start = true; + } + } + out + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body(t: &str) -> TextBody { + TextBody::from_text(t) + } + + #[test] + fn insert_and_split() { + let mut b = body("Hello"); + let c = insert(&mut b, (0, 5), " world\nNext", None); + assert_eq!(b.text(), "Hello world\nNext"); + assert_eq!(c, (1, 4)); + let c = insert(&mut b, (0, 0), ">", None); + assert_eq!(c, (0, 1)); + assert_eq!(b.paragraphs[0].runs.len(), 1); + } + + #[test] + fn delete_within_and_across() { + let mut b = body("abc\ndef\nghi"); + assert_eq!(delete(&mut b, (0, 1), (2, 1)), (0, 1)); + assert_eq!(b.text(), "ahi"); + let mut b = body("abcdef"); + delete(&mut b, (0, 4), (0, 2)); + assert_eq!(b.text(), "abef"); + // hostile positions + delete(&mut b, (99, 99), (0, 0)); + assert_eq!(b.text(), ""); + } + + #[test] + fn format_splits_runs() { + let mut b = body("abcdef"); + format(&mut b, (0, 2), (0, 4), &|p| p.bold = Some(true)); + let p = &b.paragraphs[0]; + assert_eq!(p.runs.len(), 3); + assert_eq!(p.runs[1].text, "cd"); + assert_eq!(p.runs[1].props.bold, Some(true)); + assert_eq!(props_at(&b, (0, 3)).bold, Some(true)); + assert_eq!(text_range(&b, (0, 1), (0, 5)), "bcde"); + let s = slice(&b, (0, 1), (0, 5)); + assert_eq!(s.text(), "bcde"); + let mut c = body("XY"); + let caret = insert_body(&mut c, (0, 1), &s); + assert_eq!(c.text(), "XbcdeY"); + assert_eq!(caret, (0, 5)); + } + + #[test] + fn words_and_moves() { + let b = body("Hello brave world"); + assert_eq!(word_at(&b, (0, 7)), ((0, 6), (0, 12))); + assert_eq!(word_move(&b, (0, 0), true), (0, 6)); + assert_eq!(word_move(&b, (0, 8), false), (0, 6)); + assert_eq!(char_move(&b, (0, 17), true), (0, 17)); + assert_eq!(change_case("hello world. again", "sentence"), "Hello world. Again"); + assert_eq!(change_case("hello world", "title"), "Hello World"); + } + + #[test] + fn line_break_insert() { + let mut b = body("ab"); + let c = insert(&mut b, (0, 1), "\u{b}", None); + assert_eq!(c, (0, 2)); + assert_eq!(b.paragraphs[0].char_len(), 3); + assert_eq!(b.paragraphs.len(), 1); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs new file mode 100644 index 0000000..c69eb24 --- /dev/null +++ b/crates/model/src/lib.rs @@ -0,0 +1,882 @@ +//! The SlideCraft document model. +//! +//! A [`Presentation`] has slide masters (each with a theme and layouts) and slides. Each slide +//! uses a layout; placeholders on a slide inherit position, formatting and text styles from the +//! matching placeholder on its layout and master ([`resolve`]). Slides and masters sit behind +//! `Arc`s so an undo snapshot of the whole deck costs one clone of the slide list. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +pub mod anim; +pub mod chart; +pub mod defaults; +pub mod edit; +pub mod resolve; +pub mod style; +pub mod table; +pub mod text; +pub mod theme; + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +pub use slidecraft_color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; +pub use slidecraft_geom::{Rect, Size, Xfrm}; + +pub use anim::{AnimClass, AnimStart, Animation, Transition}; +pub use chart::Chart; +pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; +pub use table::{Cell, Table}; +pub use text::{Paragraph, Run, RunProps, TextBody}; +pub use theme::Theme; + +pub(crate) fn yes() -> bool { + true +} + +macro_rules! id_type { + ($name:ident) => { + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub u32); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} +id_type!(SlideId); +id_type!(ShapeId); +id_type!(LayoutId); +id_type!(MasterId); +id_type!(MediaId); + +#[derive(Debug, thiserror::Error)] +pub enum ModelError { + #[error("no slide {0}")] + NoSlide(String), + #[error("no shape {0}")] + NoShape(ShapeId), + #[error("no layout {0}")] + NoLayout(LayoutId), + #[error("{0}")] + Invalid(String), +} + +pub type Result = std::result::Result; + +/// Placeholder types. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PhType { + Title, + CtrTitle, + SubTitle, + #[default] + Body, + Obj, + Chart, + Table, + ClipArt, + Diagram, + Media, + Picture, + Date, + Footer, + SlideNum, + Header, + SlideImage, +} + +impl PhType { + pub fn xml(self) -> &'static str { + match self { + PhType::Title => "title", + PhType::CtrTitle => "ctrTitle", + PhType::SubTitle => "subTitle", + PhType::Body => "body", + PhType::Obj => "obj", + PhType::Chart => "chart", + PhType::Table => "tbl", + PhType::ClipArt => "clipArt", + PhType::Diagram => "dgm", + PhType::Media => "media", + PhType::Picture => "pic", + PhType::Date => "dt", + PhType::Footer => "ftr", + PhType::SlideNum => "sldNum", + PhType::Header => "hdr", + PhType::SlideImage => "sldImg", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "title" => PhType::Title, + "ctrTitle" => PhType::CtrTitle, + "subTitle" => PhType::SubTitle, + "obj" => PhType::Obj, + "chart" => PhType::Chart, + "tbl" => PhType::Table, + "clipArt" => PhType::ClipArt, + "dgm" => PhType::Diagram, + "media" => PhType::Media, + "pic" => PhType::Picture, + "dt" => PhType::Date, + "ftr" => PhType::Footer, + "sldNum" => PhType::SlideNum, + "hdr" => PhType::Header, + "sldImg" => PhType::SlideImage, + _ => PhType::Body, + } + } + pub fn is_title(self) -> bool { + matches!(self, PhType::Title | PhType::CtrTitle) + } + /// The prompt shown in an empty placeholder in Normal view. + pub fn prompt(self) -> &'static str { + match self { + PhType::Title | PhType::CtrTitle => "Click to add title", + PhType::SubTitle => "Click to add subtitle", + PhType::Body | PhType::Obj => "Click to add text", + PhType::Chart => "Click icon to add chart", + PhType::Table => "Click icon to add table", + PhType::Diagram => "Click icon to add SmartArt graphic", + PhType::Media => "Click icon to add media", + PhType::Picture | PhType::ClipArt => "Click icon to add picture", + PhType::Date => "Date", + PhType::Footer => "Footer", + PhType::SlideNum => "‹#›", + PhType::Header => "Header", + PhType::SlideImage => "", + } + } + /// Footer-row placeholders are not shown on slides unless the deck turns them on. + pub fn is_footer_kind(self) -> bool { + matches!(self, PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placeholder { + pub kind: PhType, + pub idx: u32, + /// `vert` orientation flag. + pub vertical: bool, + /// `quarter`, `half`, `full` size hint. + pub size: Option, + pub has_custom_prompt: bool, +} + +/// Shape geometry: a preset name with adjust values, or a custom path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Geom { + Preset { + name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + adj: Vec, + }, + /// Custom geometry: sub-paths in their own coordinate space (w, h) scaled to the shape. + Custom { paths: Vec }, +} + +impl Default for Geom { + fn default() -> Self { + Geom::Preset { name: "rect".into(), adj: vec![] } + } +} + +impl Geom { + pub fn preset(name: &str) -> Self { + Geom::Preset { name: name.into(), adj: vec![] } + } + pub fn preset_name(&self) -> Option<&str> { + match self { + Geom::Preset { name, .. } => Some(name), + Geom::Custom { .. } => None, + } + } +} + +/// One sub-path of a custom geometry: SVG-like commands in path units. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomPath { + pub w: f64, + pub h: f64, + /// `M x y`, `L x y`, `C x1 y1 x2 y2 x y`, `Q x1 y1 x y`, `Z`. + pub d: String, + pub fill: slidecraft_geom::preset::FillMode, + pub stroke: bool, +} + +impl Default for CustomPath { + fn default() -> Self { + CustomPath { w: 0.0, h: 0.0, d: String::new(), fill: slidecraft_geom::preset::FillMode::Norm, stroke: true } + } +} + +/// Embedded audio or video on a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MediaClip { + pub media: MediaId, + pub video: bool, + /// Poster frame image. + pub poster: Option, + pub trim_start_ms: u32, + pub trim_end_ms: u32, + pub fade_in_ms: u32, + pub fade_out_ms: u32, + /// 0–1. + pub volume: f64, + pub autoplay: bool, + pub loop_play: bool, + pub rewind: bool, + pub play_across_slides: bool, + pub hide_while_not_playing: bool, + pub full_screen: bool, + /// Bookmarks (name, ms). + pub bookmarks: Vec<(String, u32)>, +} + +/// One ink stroke (Draw tab): points in slide coordinates with pressure. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct InkStroke { + pub points: Vec<(f64, f64, f32)>, + pub color: Rgba, + pub width: f64, + pub highlighter: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum ShapeKind { + /// An autoshape or text box. + Shape, + Picture { + fill: style::PictureFill, + }, + Group { + children: Vec, + /// Child coordinate space. + child: Xfrm, + }, + Connector { + start: Option<(ShapeId, u32)>, + end: Option<(ShapeId, u32)>, + }, + Table(Table), + Chart(Box), + Media(MediaClip), + Ink { + strokes: Vec, + }, + /// Something we read but don't model (SmartArt without fallback, OLE…): its XML is kept and its + /// fallback picture, if any, is shown. + Opaque { + xml: String, + preview: Option, + label: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Shape { + pub id: ShapeId, + pub name: String, + /// `None`: placeholder inheriting its box from the layout/master. + pub xfrm: Option, + pub kind: ShapeKind, + pub geom: Geom, + pub ph: Option, + /// `None`: inherited (placeholder chain or style); `Some(Fill::None)`: no fill. + pub fill: Option, + pub line: Option, + pub effects: Option, + pub style: Option, + pub text: Option, + pub hidden: bool, + pub locked: bool, + /// Text box (`txBox="1"`): no fill, autosize, wrap off by default. + pub text_box: bool, + /// Alt text. + pub descr: String, + pub title: String, + pub decorative: bool, + pub click: Option, + pub hover: Option, + /// Unmodelled XML kept for round-trip (extension lists etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Default for Shape { + fn default() -> Self { + Shape { + id: ShapeId(0), + name: String::new(), + xfrm: None, + kind: ShapeKind::Shape, + geom: Geom::default(), + ph: None, + fill: None, + line: None, + effects: None, + style: None, + text: None, + hidden: false, + locked: false, + text_box: false, + descr: String::new(), + title: String::new(), + decorative: false, + click: None, + hover: None, + raw_ext: None, + } + } +} + +impl Shape { + pub fn is_placeholder(&self) -> bool { + self.ph.is_some() + } + pub fn ph_type(&self) -> Option { + self.ph.as_ref().map(|p| p.kind) + } + pub fn is_group(&self) -> bool { + matches!(self.kind, ShapeKind::Group { .. }) + } + pub fn children(&self) -> &[Shape] { + match &self.kind { + ShapeKind::Group { children, .. } => children, + _ => &[], + } + } + pub fn children_mut(&mut self) -> Option<&mut Vec> { + match &mut self.kind { + ShapeKind::Group { children, .. } => Some(children), + _ => None, + } + } + /// Is this a line-like shape (no area)? + pub fn is_line(&self) -> bool { + matches!(self.kind, ShapeKind::Connector { .. }) || self.geom.preset_name().is_some_and(slidecraft_geom::preset::is_line_like) + } + pub fn kind_name(&self) -> &'static str { + match &self.kind { + ShapeKind::Shape if self.ph.is_some() => "placeholder", + ShapeKind::Shape if self.text_box => "textBox", + ShapeKind::Shape => "shape", + ShapeKind::Picture { .. } => "picture", + ShapeKind::Group { .. } => "group", + ShapeKind::Connector { .. } => "connector", + ShapeKind::Table(_) => "table", + ShapeKind::Chart(_) => "chart", + ShapeKind::Media(m) if m.video => "video", + ShapeKind::Media(_) => "audio", + ShapeKind::Ink { .. } => "ink", + ShapeKind::Opaque { .. } => "object", + } + } +} + +/// Walk shapes depth-first (groups included). +pub fn walk<'a>(shapes: &'a [Shape], f: &mut dyn FnMut(&'a Shape, usize)) { + fn rec<'a>(shapes: &'a [Shape], depth: usize, f: &mut dyn FnMut(&'a Shape, usize)) { + if depth > 64 { + return; + } + for s in shapes { + f(s, depth); + rec(s.children(), depth + 1, f); + } + } + rec(shapes, 0, f); +} + +pub fn find_shape(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + fn rec(shapes: &[Shape], id: ShapeId, depth: usize) -> Option<&Shape> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(s); + } + if let Some(f) = rec(s.children(), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> { + fn rec(shapes: &mut [Shape], id: ShapeId, depth: usize) -> Option<&mut Shape> { + if depth > 64 { + return None; + } + for s in shapes.iter_mut() { + if s.id == id { + return Some(s); + } + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// Background of a slide, layout or master. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Background { + Fill { + fill: Fill, + }, + /// Theme background style reference (1001–1003) with a colour. + Ref { + idx: u32, + color: ColorRef, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Comment { + pub author: String, + pub initials: String, + pub text: String, + /// ISO 8601. + pub date: String, + pub x: f64, + pub y: f64, + pub resolved: bool, + pub replies: Vec, + /// The shape the comment is attached to. + pub shape: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Slide { + pub id: SlideId, + pub layout: LayoutId, + pub name: String, + pub shapes: Vec, + pub background: Option, + pub hidden: bool, + pub show_master_shapes: bool, + pub transition: Option, + pub animations: Vec, + /// Speaker notes. + pub notes: TextBody, + pub comments: Vec, + /// Unmodelled XML kept for round-trip (custom data, tags). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Default for Slide { + fn default() -> Self { + Slide { + id: SlideId(0), + layout: LayoutId(0), + name: String::new(), + shapes: vec![], + background: None, + hidden: false, + show_master_shapes: true, + transition: None, + animations: vec![], + notes: TextBody::default(), + comments: vec![], + raw_ext: None, + } + } +} + +impl Slide { + pub fn shape(&self, id: ShapeId) -> Option<&Shape> { + find_shape(&self.shapes, id) + } + pub fn shape_mut(&mut self, id: ShapeId) -> Option<&mut Shape> { + find_shape_mut(&mut self.shapes, id) + } + /// The slide's title text (first title placeholder), for thumbnails, outline and navigation. + pub fn title(&self) -> String { + self.shapes + .iter() + .find(|s| s.ph_type().is_some_and(PhType::is_title)) + .and_then(|s| s.text.as_ref()) + .map(|t| t.text().replace('\u{b}', " ")) + .unwrap_or_default() + } + pub fn notes_text(&self) -> String { + self.notes.text() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LayoutType { + Title, + #[default] + TitleAndContent, + SectionHeader, + TwoContent, + Comparison, + TitleOnly, + Blank, + ContentWithCaption, + PictureWithCaption, + TitleAndVerticalText, + VerticalTitleAndText, + Custom, +} + +impl LayoutType { + pub fn xml(self) -> &'static str { + match self { + LayoutType::Title => "title", + LayoutType::TitleAndContent => "obj", + LayoutType::SectionHeader => "secHead", + LayoutType::TwoContent => "twoObj", + LayoutType::Comparison => "twoTxTwoObj", + LayoutType::TitleOnly => "titleOnly", + LayoutType::Blank => "blank", + LayoutType::ContentWithCaption => "objTx", + LayoutType::PictureWithCaption => "picTx", + LayoutType::TitleAndVerticalText => "vertTx", + LayoutType::VerticalTitleAndText => "vertTitleAndTx", + LayoutType::Custom => "cust", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "title" => LayoutType::Title, + "obj" | "tx" => LayoutType::TitleAndContent, + "secHead" => LayoutType::SectionHeader, + "twoObj" | "twoColTx" => LayoutType::TwoContent, + "twoTxTwoObj" => LayoutType::Comparison, + "titleOnly" => LayoutType::TitleOnly, + "blank" => LayoutType::Blank, + "objTx" => LayoutType::ContentWithCaption, + "picTx" => LayoutType::PictureWithCaption, + "vertTx" => LayoutType::TitleAndVerticalText, + "vertTitleAndTx" => LayoutType::VerticalTitleAndText, + _ => LayoutType::Custom, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Layout { + pub id: LayoutId, + pub name: String, + pub kind: LayoutType, + pub shapes: Vec, + pub background: Option, + pub show_master_shapes: bool, + pub preserve: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +/// Which placeholders slides show in the footer row (Header & Footer dialog). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HeaderFooter { + pub date: bool, + pub slide_number: bool, + pub footer: bool, + pub header: bool, + pub footer_text: String, + /// Fixed date text; empty = automatic date. + pub date_text: String, + /// `datetime1`…`datetime13` format for automatic dates. + pub date_format: String, + pub hide_on_title: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Master { + pub id: MasterId, + pub name: String, + pub theme: Theme, + pub shapes: Vec, + pub background: Option, + pub title_style: text::ListStyle, + pub body_style: text::ListStyle, + pub other_style: text::ListStyle, + pub layouts: Vec, + /// `bg1`→`lt1` etc. Default mapping when empty. + pub color_map: Vec<(String, String)>, + pub preserve: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Master { + pub fn layout(&self, id: LayoutId) -> Option<&Layout> { + self.layouts.iter().find(|l| l.id == id) + } + /// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map. + pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot { + let key = match slot { + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + other => return other, + }; + self.color_map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { + SchemeSlot::Bg1 => SchemeSlot::Lt1, + SchemeSlot::Tx1 => SchemeSlot::Dk1, + SchemeSlot::Bg2 => SchemeSlot::Lt2, + _ => SchemeSlot::Dk2, + }) + } + /// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours). + pub fn scheme(&self) -> ColorScheme { + let mut s = self.theme.colors.clone(); + if self.color_map.is_empty() { + return s; + } + let base = self.theme.colors.clone(); + for (alias, slot) in [ + (SchemeSlot::Bg1, SchemeSlot::Lt1), + (SchemeSlot::Tx1, SchemeSlot::Dk1), + (SchemeSlot::Bg2, SchemeSlot::Lt2), + (SchemeSlot::Tx2, SchemeSlot::Dk2), + ] { + s.set(slot, base.get(self.map_slot(alias))); + } + s + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Section { + pub name: String, + pub slides: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomShow { + pub name: String, + pub slides: Vec, +} + +/// Slide Show ▸ Set Up Slide Show. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShowSettings { + /// `speaker`, `browsed`, `kiosk`. + pub show_type: String, + pub loop_until_esc: bool, + pub without_narration: bool, + pub without_animation: bool, + pub use_timings: bool, + /// 1-based inclusive range; `None` = all. + pub range: Option<(u32, u32)>, + pub custom_show: Option, + pub pen_color: Rgba, + pub laser_color: Rgba, + pub show_media_controls: bool, +} + +impl Default for ShowSettings { + fn default() -> Self { + ShowSettings { + show_type: "speaker".into(), + loop_until_esc: false, + without_narration: false, + without_animation: false, + use_timings: true, + range: None, + custom_show: None, + pen_color: Rgba::rgb(0xFF, 0x00, 0x00), + laser_color: Rgba::rgb(0xFF, 0x00, 0x00), + show_media_controls: true, + } + } +} + +/// Document properties (File ▸ Properties). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Properties { + pub title: String, + pub subject: String, + pub author: String, + pub keywords: String, + pub comments: String, + pub category: String, + pub company: String, + pub created: String, + pub modified: String, + pub last_modified_by: String, + pub revision: u32, +} + +/// An embedded file (image, audio, video, font, workbook). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MediaItem { + pub id: MediaId, + /// Original file name, e.g. `image1.png`. + pub name: String, + pub content_type: String, + /// Bytes; kept out of the JSON manifest and stored as a separate file in native files. + #[serde(skip)] + pub data: Arc>, + /// External link instead of embedded bytes. + pub link: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Presentation { + /// Slide size in points (default 16:9, 960 × 540). + pub slide_size: Size, + pub notes_size: Size, + pub slides: Vec>, + pub masters: Vec>, + pub notes_master: Option>, + pub handout_master: Option>, + pub sections: Vec
, + pub custom_shows: Vec, + pub show: ShowSettings, + pub header_footer: HeaderFooter, + pub props: Properties, + /// Presentation-wide default text style (shapes that aren't placeholders). + pub default_text_style: text::ListStyle, + pub media: Vec, + pub first_slide_number: u32, + /// Next free shape/slide/layout/media id. + pub next_id: u32, + /// Embedded fonts (family names; bytes in `media`). + pub embedded_fonts: Vec<(String, MediaId)>, + /// Unmodelled presentation-level XML kept for round-trip. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Default for Presentation { + fn default() -> Self { + defaults::new_presentation(None) + } +} + +impl Presentation { + /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. + pub fn alloc_id(&mut self) -> u32 { + self.next_id = self.next_id.max(1).saturating_add(1); + self.next_id + } + /// Make sure `next_id` is above every id in use (after loading a file). + pub fn fix_next_id(&mut self) { + let mut m = self.next_id; + for s in &self.slides { + m = m.max(s.id.0); + walk(&s.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + for ma in &self.masters { + m = m.max(ma.id.0); + walk(&ma.shapes, &mut |sh, _| m = m.max(sh.id.0)); + for l in &ma.layouts { + m = m.max(l.id.0); + walk(&l.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + } + for md in &self.media { + m = m.max(md.id.0); + } + self.next_id = m; + } + pub fn slide_index(&self, id: SlideId) -> Option { + self.slides.iter().position(|s| s.id == id) + } + pub fn slide(&self, id: SlideId) -> Option<&Slide> { + self.slides.iter().find(|s| s.id == id).map(|s| s.as_ref()) + } + pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> { + self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut) + } + pub fn slide_at(&self, i: usize) -> Option<&Slide> { + self.slides.get(i).map(|s| s.as_ref()) + } + /// Master and layout for a layout id. + pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> { + self.masters.iter().find_map(|m| m.layout(id).map(|l| (m.as_ref(), l))) + } + /// The master (and layout) a slide uses, falling back to the first. + pub fn master_for(&self, slide: &Slide) -> Option<(&Master, Option<&Layout>)> { + if let Some((m, l)) = self.layout(slide.layout) { + return Some((m, Some(l))); + } + self.masters.first().map(|m| (m.as_ref(), m.layouts.first())) + } + pub fn first_master(&self) -> Option<&Master> { + self.masters.first().map(|m| m.as_ref()) + } + pub fn media(&self, id: MediaId) -> Option<&MediaItem> { + self.media.iter().find(|m| m.id == id) + } + /// Add media bytes, reusing an identical existing item. + pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec) -> MediaId { + if let Some(m) = self.media.iter().find(|m| m.data.len() == data.len() && *m.data == data) { + return m.id; + } + let id = MediaId(self.alloc_id()); + self.media.push(MediaItem { id, name: name.into(), content_type: content_type.into(), data: Arc::new(data), link: None }); + id + } + /// Section index containing a slide (by position), when sections exist. + pub fn section_of(&self, slide: SlideId) -> Option { + self.sections.iter().position(|s| s.slides.contains(&slide)) + } + /// 1-based slide number as displayed (respects `first_slide_number`). + pub fn slide_number(&self, index: usize) -> u32 { + self.first_slide_number.saturating_add(u32::try_from(index).unwrap_or(u32::MAX)) + } + /// Basic consistency check: unique slide/shape ids, layouts exist. Returns problems found. + pub fn validate(&self) -> Vec { + let mut out = vec![]; + let mut seen = std::collections::HashSet::new(); + for s in &self.slides { + if !seen.insert(s.id) { + out.push(format!("duplicate slide id {}", s.id)); + } + if self.layout(s.layout).is_none() { + out.push(format!("slide {} uses missing layout {}", s.id, s.layout)); + } + let mut ids = std::collections::HashSet::new(); + walk(&s.shapes, &mut |sh, _| { + if !ids.insert(sh.id) { + out.push(format!("slide {}: duplicate shape id {}", s.id, sh.id)); + } + }); + } + out + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/model/src/resolve.rs b/crates/model/src/resolve.rs new file mode 100644 index 0000000..2abab4b --- /dev/null +++ b/crates/model/src/resolve.rs @@ -0,0 +1,319 @@ +//! Inheritance: effective position, fill, line, body and text properties of a shape, following +//! slide → layout placeholder → master placeholder → master text styles → theme. + +use slidecraft_color::{ColorScheme, Rgba, SchemeSlot}; +use slidecraft_geom::Xfrm; + +use crate::style::{ColorRef, Effects, Fill, Line}; +use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps}; +use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide}; + +/// Where a shape lives, for resolving inherited values. +#[derive(Clone, Copy)] +pub enum Owner { + Slide, + Layout, + Master, +} + +pub struct Ctx<'a> { + pub pres: &'a Presentation, + pub master: &'a Master, + pub layout: Option<&'a Layout>, + pub owner: Owner, + pub scheme: ColorScheme, +} + +impl<'a> Ctx<'a> { + pub fn for_slide(pres: &'a Presentation, slide: &Slide) -> Option> { + let (master, layout) = pres.master_for(slide)?; + Some(Ctx { pres, master, layout, owner: Owner::Slide, scheme: master.scheme() }) + } + pub fn for_layout(pres: &'a Presentation, master: &'a Master, layout: &'a Layout) -> Ctx<'a> { + Ctx { pres, master, layout: Some(layout), owner: Owner::Layout, scheme: master.scheme() } + } + pub fn for_master(pres: &'a Presentation, master: &'a Master) -> Ctx<'a> { + Ctx { pres, master, layout: None, owner: Owner::Master, scheme: master.scheme() } + } + pub fn color(&self, c: &ColorRef, ph: Option) -> Rgba { + let c = match &c.base { + crate::ColorBase::Scheme { slot } if matches!(slot, SchemeSlot::Bg1 | SchemeSlot::Tx1 | SchemeSlot::Bg2 | SchemeSlot::Tx2) => { + ColorRef { base: crate::ColorBase::Scheme { slot: self.master.map_slot(*slot) }, mods: c.mods.clone() } + } + _ => c.clone(), + }; + c.resolve(&self.master.theme.colors, ph) + } +} + +fn compatible(slide: PhType, other: PhType) -> bool { + use PhType::*; + match slide { + Title | CtrTitle => matches!(other, Title | CtrTitle), + SubTitle | Body | Obj | Chart | Table | ClipArt | Diagram | Media | Picture => { + !matches!(other, Title | CtrTitle | Date | Footer | SlideNum | Header) + } + k => k == other, + } +} + +fn find_ph<'s>(shapes: &'s [Shape], shape: &Shape, by_idx: bool) -> Option<&'s Shape> { + let ph = shape.ph.as_ref()?; + if by_idx + && ph.idx != 0 + && let Some(s) = shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.idx == ph.idx && compatible(ph.kind, p.kind))) + { + return Some(s); + } + // Same type first, then a compatible one. + shapes + .iter() + .find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind && (!by_idx || ph.idx == 0 || p.idx == ph.idx || ph.kind.is_title()))) + .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind))) + .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| compatible(ph.kind, p.kind)))) +} + +fn master_ph(master: &Master, kind: PhType) -> Option<&Shape> { + let want = match kind { + PhType::Title | PhType::CtrTitle => PhType::Title, + PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header => kind, + _ => PhType::Body, + }; + master.shapes.iter().find(|s| s.ph_type() == Some(want)) +} + +/// The layout and master placeholders a shape inherits from. +pub fn parents<'a>(ctx: &Ctx<'a>, shape: &Shape) -> (Option<&'a Shape>, Option<&'a Shape>) { + let Some(ph) = shape.ph.as_ref() else { return (None, None) }; + match ctx.owner { + Owner::Slide => { + let lp = ctx.layout.and_then(|l| find_ph(&l.shapes, shape, true)); + let kind = lp.and_then(Shape::ph_type).unwrap_or(ph.kind); + (lp, master_ph(ctx.master, kind)) + } + Owner::Layout => (None, master_ph(ctx.master, ph.kind)), + Owner::Master => (None, None), + } +} + +/// The text style group (title / body / other) a shape draws from. +pub fn master_list_style<'a>(ctx: &Ctx<'a>, shape: &Shape) -> &'a ListStyle { + match shape.ph_type() { + Some(PhType::Title | PhType::CtrTitle) => &ctx.master.title_style, + Some( + PhType::Body + | PhType::Obj + | PhType::SubTitle + | PhType::Table + | PhType::Chart + | PhType::Diagram + | PhType::Media + | PhType::Picture + | PhType::ClipArt, + ) => &ctx.master.body_style, + Some(_) => &ctx.master.other_style, + None => &ctx.pres.default_text_style, + } +} + +pub fn xfrm(ctx: &Ctx, shape: &Shape) -> Xfrm { + if let Some(x) = shape.xfrm { + return x; + } + let (lp, mp) = parents(ctx, shape); + lp.and_then(|s| s.xfrm).or_else(|| mp.and_then(|s| s.xfrm)).unwrap_or_else(|| { + let sz = ctx.pres.slide_size; + Xfrm::new(sz.width * 0.1, sz.height * 0.1, sz.width * 0.8, sz.height * 0.2) + }) +} + +/// Effective fill and the style colour (`phClr`) to resolve it with. +pub fn fill(ctx: &Ctx, shape: &Shape) -> (Option, Option) { + let (lp, mp) = parents(ctx, shape); + for s in [Some(shape), lp, mp].into_iter().flatten() { + if let Some(f) = &s.fill { + return (Some(f.clone()), None); + } + if let Some(st) = &s.style { + let (idx, col) = &st.fill_ref; + let ph = ctx.color(col, None); + let f = match *idx { + 0 => Some(Fill::None), + i if i >= 1001 => ctx.master.theme.format.bg_fills.get((i - 1001) as usize).cloned(), + i => ctx.master.theme.format.fills.get((i - 1) as usize).cloned(), + }; + return (f, Some(ph)); + } + } + if shape.text_box { + return (Some(Fill::None), None); + } + (None, None) +} + +pub fn line(ctx: &Ctx, shape: &Shape) -> (Line, Option) { + let (lp, mp) = parents(ctx, shape); + let mut out = Line::default(); + let mut ph = None; + for s in [Some(shape), lp, mp].into_iter().flatten() { + if let Some(l) = &s.line { + out.inherit(l); + } + if let Some(st) = &s.style { + let (idx, col) = &st.line_ref; + if ph.is_none() { + ph = Some(ctx.color(col, None)); + } + if *idx == 0 { + out.inherit(&Line::none()); + } else if let Some(l) = ctx.master.theme.format.lines.get((*idx - 1) as usize) { + out.inherit(l); + } + break; + } + } + (out, ph) +} + +pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option, Option) { + let (lp, mp) = parents(ctx, shape); + for s in [Some(shape), lp, mp].into_iter().flatten() { + if let Some(e) = &s.effects { + return (Some(e.clone()), None); + } + if let Some(st) = &s.style { + let (idx, col) = &st.effect_ref; + if *idx == 0 { + return (None, None); + } + return (ctx.master.theme.format.effects.get((*idx - 1) as usize).cloned(), Some(ctx.color(col, None))); + } + } + (None, None) +} + +/// Body properties with defaults filled in. +pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { + let (lp, mp) = parents(ctx, shape); + let mut b = shape.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); + for s in [lp, mp].into_iter().flatten() { + if let Some(t) = &s.text { + b.inherit(&t.body); + } + } + b.inherit(&BodyProps { + inset_l: Some(7.2), + inset_t: Some(3.6), + inset_r: Some(7.2), + inset_b: Some(3.6), + anchor: Some(if shape.ph.is_none() && !shape.text_box && !matches!(shape.kind, crate::ShapeKind::Table(_)) { + Anchor::Middle + } else { + Anchor::Top + }), + anchor_ctr: Some(false), + wrap: Some(true), + autofit: Some(if shape.text_box { AutoFit::Shape } else { AutoFit::None }), + vert: Some(crate::text::TextDir::Horizontal), + rot: Some(0.0), + columns: Some(1), + col_spacing: Some(0.0), + warp: None, + rtl_col: Some(false), + upright: Some(false), + }); + b +} + +/// The merged level style (paragraph + run defaults) for paragraphs at `level` in `shape`. +pub fn level_style(ctx: &Ctx, shape: &Shape, level: u8) -> LevelStyle { + let (lp, mp) = parents(ctx, shape); + let mut out = LevelStyle::default(); + let add = |out: &mut LevelStyle, ls: Option<&LevelStyle>| { + if let Some(l) = ls { + out.para.inherit(&l.para); + out.run.inherit(&l.run); + } + }; + if let Some(t) = &shape.text { + add(&mut out, t.list_style.level(level)); + } + for s in [lp, mp].into_iter().flatten() { + if let Some(t) = &s.text { + add(&mut out, t.list_style.level(level)); + } + } + // Shape style font reference (theme font + colour) sits above the defaults. + if let Some(st) = shape.style.as_ref().or(lp.and_then(|s| s.style.as_ref())) { + let font = match st.font_ref.0.as_str() { + "major" => Some("+mj-lt".to_string()), + "minor" => Some("+mn-lt".to_string()), + _ => None, + }; + let r = RunProps { font, fill: st.font_ref.1.clone().map(Fill::solid), ..Default::default() }; + out.run.inherit(&r); + } + add(&mut out, master_list_style(ctx, shape).level(level)); + add(&mut out, ctx.pres.default_text_style.level(level)); + // Last resort. + out.run.inherit(&RunProps { size: Some(18.0), font: Some("+mn-lt".into()), bold: Some(false), italic: Some(false), ..Default::default() }); + out.para.inherit(&ParaProps { margin_left: Some(0.0), indent: Some(0.0), align: Some(crate::text::Align::Left), ..Default::default() }); + out +} + +/// Fully resolved run properties for `run` in paragraph `para` (explicit → paragraph style). +pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunProps) -> RunProps { + let mut r = run.clone(); + let lvl = level_style(ctx, shape, para.level); + r.inherit(&lvl.run); + r +} + +pub fn para(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph) -> ParaProps { + let mut p = para.props.clone(); + let lvl = level_style(ctx, shape, para.level); + p.inherit(&lvl.para); + p +} + +/// Concrete font family for a run (theme references resolved). +pub fn font_family(ctx: &Ctx, r: &RunProps) -> String { + let f = r.font.as_deref().unwrap_or("+mn-lt"); + let name = ctx.master.theme.font(f); + if name.is_empty() { ctx.master.theme.fonts.minor.latin.clone() } else { name } +} + +/// Text colour of resolved run props. +pub fn text_color(ctx: &Ctx, r: &RunProps) -> Rgba { + match &r.fill { + Some(Fill::Solid { color }) => ctx.color(color, None), + Some(Fill::Gradient(g)) => g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), + Some(Fill::None) => Rgba::TRANSPARENT, + _ => ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None), + } +} + +/// The background fill (and its style colour) of a slide. +pub fn background(ctx: &Ctx, slide: Option<&Slide>) -> (Fill, Option) { + let bg = slide.and_then(|s| s.background.as_ref()).or(ctx.layout.and_then(|l| l.background.as_ref())).or(ctx.master.background.as_ref()); + match bg { + Some(Background::Fill { fill }) => (fill.clone(), None), + Some(Background::Ref { idx, color }) => { + let ph = ctx.color(color, None); + let f = if *idx >= 1001 { + ctx.master.theme.format.bg_fills.get((*idx - 1001) as usize).cloned() + } else { + ctx.master.theme.format.fills.get(idx.saturating_sub(1) as usize).cloned() + }; + (f.unwrap_or(Fill::solid(ColorRef::rgb(ph))), Some(ph)) + } + None => (Fill::solid(ColorRef::scheme(SchemeSlot::Bg1)), None), + } +} + +/// Should the layout/master graphics (non-placeholder shapes) show behind this slide? +pub fn show_master_shapes(slide: &Slide, layout: Option<&Layout>) -> (bool, bool) { + let show_layout = slide.show_master_shapes; + let show_master = show_layout && layout.is_none_or(|l| l.show_master_shapes); + (show_layout, show_master) +} diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs new file mode 100644 index 0000000..fc51251 --- /dev/null +++ b/crates/model/src/style.rs @@ -0,0 +1,434 @@ +//! Colours, fills, lines and effects. + +use serde::{Deserialize, Serialize}; +use slidecraft_color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; + +use crate::MediaId; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ColorBase { + Rgb { + rgb: Rgba, + }, + Scheme { + slot: SchemeSlot, + }, + Preset { + name: String, + }, + /// A system colour (`windowText`…) with the value it had when saved. + System { + name: String, + last: Rgba, + }, +} + +/// A colour as written in a document: a base (RGB, theme slot…) plus transforms. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ColorRef { + pub base: ColorBase, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mods: Vec, +} + +impl ColorRef { + pub fn rgb(c: Rgba) -> Self { + ColorRef { base: ColorBase::Rgb { rgb: c }, mods: vec![] } + } + pub fn scheme(slot: SchemeSlot) -> Self { + ColorRef { base: ColorBase::Scheme { slot }, mods: vec![] } + } + pub fn with(mut self, m: ColorTransform) -> Self { + self.mods.push(m); + self + } + /// Resolve against a colour scheme; `ph` substitutes `phClr` (style matrix colour). + pub fn resolve(&self, scheme: &ColorScheme, ph: Option) -> Rgba { + let base = match &self.base { + ColorBase::Rgb { rgb } => *rgb, + ColorBase::Scheme { slot: SchemeSlot::PhClr } => ph.unwrap_or_else(|| scheme.get(SchemeSlot::Accent1)), + ColorBase::Scheme { slot } => scheme.get(*slot), + ColorBase::Preset { name } => slidecraft_color::preset(name).unwrap_or(Rgba::BLACK), + ColorBase::System { last, .. } => *last, + }; + slidecraft_color::apply(base, &self.mods) + } + /// Alpha (0–1) from an `alpha` transform, if any. + pub fn alpha(&self) -> f64 { + self.mods.iter().fold(1.0, |a, m| match m { + ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0), + _ => a, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GradientStop { + /// 0–1. + pub pos: f64, + pub color: ColorRef, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum GradientShape { + /// Angle in degrees clockwise from left→right. + Linear { + angle: f64, + #[serde(default)] + scaled: bool, + }, + /// `circle`, `rect` or `shape` path gradient; `focus` is the fill-to rect as fractions (l, t, r, b). + Path { path: String, focus: [f64; 4] }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Gradient { + pub stops: Vec, + pub shape: GradientShape, + #[serde(default = "crate::yes")] + pub rotate_with_shape: bool, +} + +/// How a picture fills its box: stretched into a (fractional) inset rect, or tiled. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PictureMode { + Stretch { + #[serde(default)] + fill_rect: [f64; 4], + }, + Tile { + tx: f64, + ty: f64, + sx: f64, + sy: f64, + #[serde(default)] + flip: String, + #[serde(default)] + align: String, + }, +} + +impl Default for PictureMode { + fn default() -> Self { + PictureMode::Stretch { fill_rect: [0.0; 4] } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PictureFill { + pub media: MediaId, + /// Source crop as fractions of the image (left, top, right, bottom); negative values pad. + pub crop: [f64; 4], + pub mode: PictureMode, + /// 0–1 opacity of the picture. + pub alpha: Option, + pub adjust: PictureAdjust, +} + +/// Picture corrections and colour (Picture Format ▸ Corrections / Color / Transparency). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PictureAdjust { + /// -1..1 + pub brightness: f64, + /// -1..1 + pub contrast: f64, + /// 0..4 (1 = unchanged) + pub saturation: Option, + pub grayscale: bool, + /// Recolour duotone (dark, light). + pub duotone: Option<(ColorRef, ColorRef)>, + /// Make a colour transparent (Set Transparent Color). + pub clear_color: Option, + pub sharpen: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PatternFill { + pub preset: String, + pub fg: ColorRef, + pub bg: ColorRef, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Fill { + None, + Solid { + color: ColorRef, + }, + Gradient(Gradient), + Picture(PictureFill), + Pattern(PatternFill), + /// Use the containing group's fill. + Group, + /// Use the slide background (`useBgFill`). + Background, +} + +impl Fill { + pub fn solid(c: ColorRef) -> Self { + Fill::Solid { color: c } + } + pub fn is_none(&self) -> bool { + matches!(self, Fill::None) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LineCap { + #[default] + Flat, + Round, + Square, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LineJoin { + #[default] + Round, + Bevel, + Miter, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Compound { + #[default] + Single, + Double, + ThickThin, + ThinThick, + Triple, +} + +impl Compound { + pub fn xml(self) -> &'static str { + match self { + Compound::Single => "sng", + Compound::Double => "dbl", + Compound::ThickThin => "thickThin", + Compound::ThinThick => "thinThick", + Compound::Triple => "tri", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "dbl" => Compound::Double, + "thickThin" => Compound::ThickThin, + "thinThick" => Compound::ThinThick, + "tri" => Compound::Triple, + _ => Compound::Single, + } + } +} + +/// Dash style: a preset name (`solid`, `dot`, `dash`, `lgDash`, `dashDot`, `lgDashDot`, +/// `lgDashDotDot`, `sysDash`, `sysDot`, `sysDashDot`, `sysDashDotDot`) or custom (dash, space) pairs +/// in multiples of the line width. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Dash { + Preset { name: String }, + Custom { pattern: Vec<(f64, f64)> }, +} + +impl Dash { + pub const PRESETS: [&'static str; 11] = + ["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"]; + /// Dash/space lengths in multiples of the line width. + pub fn pattern(&self) -> Vec { + match self { + Dash::Custom { pattern } => pattern.iter().flat_map(|(d, s)| [*d, *s]).collect(), + Dash::Preset { name } => match name.as_str() { + "dot" => vec![1.0, 3.0], + "dash" => vec![4.0, 3.0], + "lgDash" => vec![8.0, 3.0], + "dashDot" => vec![4.0, 3.0, 1.0, 3.0], + "lgDashDot" => vec![8.0, 3.0, 1.0, 3.0], + "lgDashDotDot" => vec![8.0, 3.0, 1.0, 3.0, 1.0, 3.0], + "sysDash" => vec![3.0, 1.0], + "sysDot" => vec![1.0, 1.0], + "sysDashDot" => vec![3.0, 1.0, 1.0, 1.0], + "sysDashDotDot" => vec![3.0, 1.0, 1.0, 1.0, 1.0, 1.0], + _ => vec![], + }, + } + } +} + +/// Arrowhead at a line end. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LineEnd { + /// `none`, `triangle`, `stealth`, `diamond`, `oval`, `arrow`. + pub kind: String, + /// `sm`, `med`, `lg`. + #[serde(default = "med")] + pub w: String, + #[serde(default = "med")] + pub len: String, +} + +fn med() -> String { + "med".into() +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Line { + pub fill: Option, + /// Width in points. + pub width: Option, + pub dash: Option, + pub cap: Option, + pub join: Option, + pub compound: Option, + pub head: Option, + pub tail: Option, +} + +impl Line { + pub fn solid(c: ColorRef, width: f64) -> Self { + Line { fill: Some(Fill::solid(c)), width: Some(width), ..Default::default() } + } + pub fn none() -> Self { + Line { fill: Some(Fill::None), ..Default::default() } + } + /// Fill unset fields from `parent`. + pub fn inherit(&mut self, parent: &Line) { + if self.fill.is_none() { + self.fill.clone_from(&parent.fill); + } + self.width = self.width.or(parent.width); + if self.dash.is_none() { + self.dash.clone_from(&parent.dash); + } + self.cap = self.cap.or(parent.cap); + self.join = self.join.or(parent.join); + self.compound = self.compound.or(parent.compound); + if self.head.is_none() { + self.head.clone_from(&parent.head); + } + if self.tail.is_none() { + self.tail.clone_from(&parent.tail); + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Shadow { + pub color: ColorRef, + /// Blur radius, points. + pub blur: f64, + /// Distance, points. + pub dist: f64, + /// Direction, degrees (0 = right, 90 = down). + pub dir: f64, + #[serde(default)] + pub inner: bool, + /// Scale (1 = 100%) for perspective shadows. + #[serde(default = "one")] + pub sx: f64, + #[serde(default = "one")] + pub sy: f64, + #[serde(default)] + pub kx: f64, + #[serde(default)] + pub ky: f64, + #[serde(default)] + pub align: String, + #[serde(default = "crate::yes")] + pub rotate_with_shape: bool, +} + +fn one() -> f64 { + 1.0 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Glow { + pub color: ColorRef, + pub radius: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Reflection { + pub blur: f64, + pub start_alpha: f64, + pub end_alpha: f64, + pub end_pos: f64, + pub dist: f64, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Effects { + pub outer_shadow: Option, + pub inner_shadow: Option, + pub glow: Option, + /// Soft edges radius in points. + pub soft_edge: Option, + pub reflection: Option, + /// Bevel preset name for 3-D format (rendered as an approximation). + pub bevel: Option, + /// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`). + pub raw3d: Option, +} + +impl Effects { + pub fn is_empty(&self) -> bool { + self.outer_shadow.is_none() + && self.inner_shadow.is_none() + && self.glow.is_none() + && self.soft_edge.is_none() + && self.reflection.is_none() + && self.bevel.is_none() + } +} + +/// A shape's theme style references (`p:style`): which fill/line/effect of the theme's format scheme +/// to use, with which colour, and the theme font and colour for its text. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShapeStyle { + pub line_ref: (u32, ColorRef), + pub fill_ref: (u32, ColorRef), + pub effect_ref: (u32, ColorRef), + /// `major`, `minor` or `none`, with the text colour. + pub font_ref: (String, Option), +} + +impl ShapeStyle { + /// The default for newly drawn shapes: accent 1 fill, darker accent 1 outline, light text. + pub fn accent(slot: SchemeSlot) -> Self { + ShapeStyle { + line_ref: (2, ColorRef::scheme(slot).with(ColorTransform::Shade(50000))), + fill_ref: (1, ColorRef::scheme(slot)), + effect_ref: (0, ColorRef::scheme(slot)), + font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Lt1))), + } + } + /// The default for lines/connectors. + pub fn line(slot: SchemeSlot) -> Self { + ShapeStyle { + line_ref: (1, ColorRef::scheme(slot)), + fill_ref: (0, ColorRef::scheme(slot)), + effect_ref: (0, ColorRef::scheme(slot)), + font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Tx1))), + } + } +} diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs new file mode 100644 index 0000000..45de90b --- /dev/null +++ b/crates/model/src/table.rs @@ -0,0 +1,244 @@ +//! Tables (graphic frames with `a:tbl`). + +use serde::{Deserialize, Serialize}; + +use crate::style::{Fill, Line}; +use crate::text::{Anchor, TextBody}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Cell { + pub text: TextBody, + pub fill: Option, + /// Left, right, top, bottom borders; `None` = from the table style. + pub borders: [Option; 4], + pub diag_down: Option, + pub diag_up: Option, + /// Margins (l, r, t, b) in points; `None` = defaults (7.2, 7.2, 3.6, 3.6). + pub margins: Option<[f64; 4]>, + pub anchor: Option, + pub grid_span: u32, + pub row_span: u32, + /// Covered by a merge to the left / above. + pub h_merge: bool, + pub v_merge: bool, + pub vertical: Option, +} + +impl Cell { + pub fn new(text: &str) -> Self { + Cell { text: TextBody::from_text(text), grid_span: 1, row_span: 1, ..Default::default() } + } + pub fn is_covered(&self) -> bool { + self.h_merge || self.v_merge + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Row { + pub height: f64, + pub cells: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Table { + /// Column widths in points. + pub cols: Vec, + pub rows: Vec, + /// Table style id (our built-in style names or a PPTX style GUID). + pub style: String, + pub first_row: bool, + pub first_col: bool, + pub last_row: bool, + pub last_col: bool, + pub band_row: bool, + pub band_col: bool, +} + +impl Default for Table { + fn default() -> Self { + Table { + cols: vec![], + rows: vec![], + style: "medium2-accent1".into(), + first_row: true, + first_col: false, + last_row: false, + last_col: false, + band_row: true, + band_col: false, + } + } +} + +impl Table { + pub fn new(rows: usize, cols: usize, width: f64, row_height: f64) -> Self { + let rows = rows.clamp(1, 1000); + let cols = cols.clamp(1, 1000); + let cw = if cols > 0 { width / cols as f64 } else { width }; + Table { + cols: vec![cw; cols], + rows: (0..rows).map(|_| Row { height: row_height, cells: (0..cols).map(|_| Cell::new("")).collect() }).collect(), + ..Default::default() + } + } + pub fn n_rows(&self) -> usize { + self.rows.len() + } + pub fn n_cols(&self) -> usize { + self.cols.len() + } + pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { + self.rows.get(r).and_then(|row| row.cells.get(c)) + } + pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { + self.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) + } + pub fn width(&self) -> f64 { + self.cols.iter().sum() + } + pub fn height(&self) -> f64 { + self.rows.iter().map(|r| r.height).sum() + } + pub fn insert_row(&mut self, at: usize) { + let at = at.min(self.rows.len()); + let h = self.rows.get(at.saturating_sub(1)).map(|r| r.height).unwrap_or(30.0); + self.rows.insert(at, Row { height: h, cells: (0..self.cols.len()).map(|_| Cell::new("")).collect() }); + } + pub fn insert_col(&mut self, at: usize) { + let at = at.min(self.cols.len()); + let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0); + self.cols.insert(at, w); + for r in &mut self.rows { + let i = at.min(r.cells.len()); + r.cells.insert(i, Cell::new("")); + } + } + pub fn delete_row(&mut self, at: usize) -> bool { + if self.rows.len() <= 1 || at >= self.rows.len() { + return false; + } + self.rows.remove(at); + true + } + pub fn delete_col(&mut self, at: usize) -> bool { + if self.cols.len() <= 1 || at >= self.cols.len() { + return false; + } + self.cols.remove(at); + for r in &mut self.rows { + if at < r.cells.len() { + r.cells.remove(at); + } + } + true + } + /// Merge the rectangle (r0,c0)–(r1,c1) inclusive into its top-left cell. + pub fn merge(&mut self, r0: usize, c0: usize, r1: usize, c1: usize) -> bool { + let (r0, r1) = (r0.min(r1), r0.max(r1)); + let (c0, c1) = (c0.min(c1), c0.max(c1)); + if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) { + return false; + } + let mut text = vec![]; + for r in r0..=r1 { + for c in c0..=c1 { + if let Some(cell) = self.cell_mut(r, c) { + if !cell.text.is_empty() { + text.append(&mut cell.text.paragraphs); + } + cell.h_merge = c > c0; + cell.v_merge = r > r0; + cell.grid_span = 1; + cell.row_span = 1; + } + } + } + if let Some(cell) = self.cell_mut(r0, c0) { + cell.grid_span = (c1 - c0 + 1) as u32; + cell.row_span = (r1 - r0 + 1) as u32; + cell.h_merge = false; + cell.v_merge = false; + if !text.is_empty() { + cell.text.paragraphs = text; + } + } + true + } + /// Undo a merge at (r, c). + pub fn split(&mut self, r: usize, c: usize) -> bool { + let Some(cell) = self.cell(r, c) else { return false }; + let (gs, rs) = (cell.grid_span.max(1) as usize, cell.row_span.max(1) as usize); + if gs == 1 && rs == 1 { + return false; + } + for rr in r..r + rs { + for cc in c..c + gs { + if let Some(x) = self.cell_mut(rr, cc) { + x.h_merge = false; + x.v_merge = false; + x.grid_span = 1; + x.row_span = 1; + } + } + } + true + } + /// Make every row have exactly `cols.len()` cells (repair after loading). + pub fn normalize(&mut self) { + let n = self.cols.len(); + for r in &mut self.rows { + r.cells.resize_with(n, || Cell::new("")); + for c in &mut r.cells { + c.grid_span = c.grid_span.max(1); + c.row_span = c.row_span.max(1); + } + } + } +} + +/// Built-in table styles: (id, label). Styles are generated from the theme accents. +pub fn table_styles() -> Vec<(String, String)> { + let mut v = vec![("none".to_string(), "No Style, No Grid".to_string()), ("grid".to_string(), "No Style, Table Grid".to_string())]; + for (kind, label) in [ + ("light1", "Light Style 1"), + ("light2", "Light Style 2"), + ("medium2", "Medium Style 2"), + ("medium4", "Medium Style 4"), + ("dark1", "Dark Style 1"), + ] { + v.push((format!("{kind}-tx1"), label.to_string())); + for a in 1..=6 { + v.push((format!("{kind}-accent{a}"), format!("{label} - Accent {a}"))); + } + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_delete_merge_split() { + let mut t = Table::new(3, 3, 300.0, 30.0); + assert_eq!((t.n_rows(), t.n_cols()), (3, 3)); + t.insert_row(1); + t.insert_col(3); + assert_eq!((t.n_rows(), t.n_cols()), (4, 4)); + assert!(t.delete_row(0) && t.delete_col(0)); + assert_eq!((t.n_rows(), t.n_cols()), (3, 3)); + assert!(t.merge(0, 0, 1, 1)); + assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((2, 2))); + assert!(t.cell(1, 1).is_some_and(|c| c.is_covered())); + assert!(t.split(0, 0)); + assert!(!t.cell(1, 1).is_some_and(|c| c.is_covered())); + assert!(!t.merge(5, 5, 6, 6)); + assert!(!t.delete_row(99)); + let mut one = Table::new(1, 1, 10.0, 10.0); + assert!(!one.delete_row(0) && !one.delete_col(0)); + assert!(table_styles().len() > 30); + } +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs new file mode 100644 index 0000000..f434be1 --- /dev/null +++ b/crates/model/src/tests.rs @@ -0,0 +1,134 @@ +use super::*; +use crate::resolve::Ctx; + +#[test] +fn new_presentation_is_valid() { + let p = Presentation::default(); + assert_eq!(p.slides.len(), 1); + assert_eq!(p.masters.len(), 1); + assert_eq!(p.masters[0].layouts.len(), 11); + assert!(p.validate().is_empty(), "{:?}", p.validate()); + let s = &p.slides[0]; + let (m, l) = p.master_for(s).unwrap(); + assert_eq!(l.unwrap().kind, LayoutType::Title); + assert_eq!(m.theme.name, "SlideCraft"); + // Title slide has a centred title and a subtitle. + let kinds: Vec<_> = s.shapes.iter().filter_map(|x| x.ph_type()).collect(); + assert_eq!(kinds, vec![PhType::CtrTitle, PhType::SubTitle]); +} + +#[test] +fn placeholder_inherits_position_and_styles() { + let p = Presentation::default(); + let s = &p.slides[0]; + let ctx = Ctx::for_slide(&p, s).unwrap(); + let title = &s.shapes[0]; + let x = resolve::xfrm(&ctx, title); + assert!((x.x - 120.0).abs() < 1e-9 && (x.w - 720.0).abs() < 1e-9); + let lvl = resolve::level_style(&ctx, title, 0); + assert_eq!(lvl.run.size, Some(60.0)); // layout overrides the master's 44 + assert_eq!(lvl.para.align, Some(text::Align::Center)); + assert_eq!(resolve::font_family(&ctx, &lvl.run), "Inter"); + let body = resolve::body(&ctx, title); + assert_eq!(body.anchor, Some(text::Anchor::Bottom)); +} + +#[test] +fn body_levels_from_master() { + let mut p = Presentation::default(); + let lay = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let s = defaults::new_slide(&mut p, lay); + let ctx = Ctx::for_slide(&p, &s).unwrap(); + let body = &s.shapes[1]; + assert_eq!(body.ph_type(), Some(PhType::Obj)); + let l0 = resolve::level_style(&ctx, body, 0); + let l1 = resolve::level_style(&ctx, body, 1); + assert_eq!(l0.run.size, Some(28.0)); + assert_eq!(l1.run.size, Some(24.0)); + assert!(matches!(l0.para.bullet, Some(text::Bullet::Char { .. }))); +} + +#[test] +fn shape_style_fill_resolves_through_theme() { + let p = Presentation::default(); + let s = &p.slides[0]; + let ctx = Ctx::for_slide(&p, s).unwrap(); + let sh = Shape { style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), xfrm: Some(Xfrm::new(0.0, 0.0, 10.0, 10.0)), ..Default::default() }; + let (f, ph) = resolve::fill(&ctx, &sh); + let Some(Fill::Solid { color }) = f else { panic!("expected solid") }; + assert_eq!(ctx.color(&color, ph), p.masters[0].theme.colors.get(SchemeSlot::Accent1)); + let (l, ph) = resolve::line(&ctx, &sh); + assert!(l.width.is_some()); + assert!(ph.is_some()); +} + +#[test] +fn serde_roundtrip() { + let p = Presentation::default(); + let j = serde_json::to_string(&p).unwrap(); + let q: Presentation = serde_json::from_str(&j).unwrap(); + assert_eq!(p, q); + // Unknown and missing fields are tolerated. + let q: Presentation = serde_json::from_str(r#"{"slides":[],"zzz":1}"#).unwrap(); + assert!(q.slides.is_empty()); +} + +#[test] +fn ids_are_unique_and_fix_next_id() { + let mut p = Presentation::default(); + let a = p.alloc_id(); + let b = p.alloc_id(); + assert!(b > a); + p.next_id = 0; + p.fix_next_id(); + assert!(p.next_id >= b.min(p.next_id)); + let c = p.alloc_id(); + let mut all = vec![]; + for s in &p.slides { + all.push(s.id.0); + walk(&s.shapes, &mut |sh, _| all.push(sh.id.0)); + } + assert!(!all.contains(&c)); +} + +#[test] +fn group_find_and_walk() { + let child = Shape { id: ShapeId(900), ..Default::default() }; + let g = Shape { id: ShapeId(901), kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 1.0, 1.0) }, ..Default::default() }; + let mut slide = Slide { shapes: vec![g], ..Default::default() }; + assert!(slide.shape(ShapeId(900)).is_some()); + assert!(slide.shape_mut(ShapeId(900)).is_some()); + let mut n = 0; + walk(&slide.shapes, &mut |_, _| n += 1); + assert_eq!(n, 2); +} + +#[test] +fn text_body_helpers() { + let mut t = TextBody::from_text("Hello\nWorld"); + assert_eq!(t.text(), "Hello\nWorld"); + assert_eq!(t.char_len(), 11); + t.paragraphs[0].runs.push(Run::new(" there")); + t.paragraphs[0].normalize(); + assert_eq!(t.paragraphs[0].runs.len(), 1); + assert_eq!(t.paragraphs[0].text(), "Hello there"); +} + +#[test] +fn builtin_themes_have_distinct_names() { + let t = theme::builtin_themes(); + let mut names: Vec<_> = t.iter().map(|t| t.name.clone()).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), t.len()); + assert!(t.len() >= 8); +} + +#[test] +fn four_by_three_master_scales() { + let p = defaults::blank_presentation(defaults::STANDARD, Theme::default(), true); + let m = &p.masters[0]; + let title = m.shapes.iter().find(|s| s.ph_type() == Some(PhType::Title)).unwrap(); + let x = title.xfrm.unwrap(); + assert!(x.x + x.w <= 720.0 + 1e-6); +} diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs new file mode 100644 index 0000000..c8e0a3a --- /dev/null +++ b/crates/model/src/text.rs @@ -0,0 +1,529 @@ +//! Text bodies: body properties, paragraphs, runs, list styles. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Anchor { + Top, + Middle, + Bottom, + Justified, + Distributed, +} + +impl Anchor { + pub fn xml(self) -> &'static str { + match self { + Anchor::Top => "t", + Anchor::Middle => "ctr", + Anchor::Bottom => "b", + Anchor::Justified => "just", + Anchor::Distributed => "dist", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "t" => Anchor::Top, + "ctr" => Anchor::Middle, + "b" => Anchor::Bottom, + "just" => Anchor::Justified, + "dist" => Anchor::Distributed, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum AutoFit { + /// Do not autofit. + None, + /// Shrink text on overflow: font scale and line-spacing reduction (0–1). + Shrink { font_scale: f64, line_reduction: f64 }, + /// Resize shape to fit text. + Shape, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextDir { + Horizontal, + Vertical, + Vertical270, + Stacked, + EaVertical, +} + +impl TextDir { + pub fn xml(self) -> &'static str { + match self { + TextDir::Horizontal => "horz", + TextDir::Vertical => "vert", + TextDir::Vertical270 => "vert270", + TextDir::Stacked => "wordArtVert", + TextDir::EaVertical => "eaVert", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "horz" => TextDir::Horizontal, + "vert" | "mongolianVert" => TextDir::Vertical, + "vert270" => TextDir::Vertical270, + "wordArtVert" | "wordArtVertRtl" => TextDir::Stacked, + "eaVert" => TextDir::EaVertical, + _ => return None, + }) + } +} + +/// `bodyPr`: insets in points, anchoring, wrapping, autofit, columns, direction, warp. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BodyProps { + pub inset_l: Option, + pub inset_t: Option, + pub inset_r: Option, + pub inset_b: Option, + pub anchor: Option, + pub anchor_ctr: Option, + pub wrap: Option, + pub autofit: Option, + pub vert: Option, + /// Extra text rotation in degrees. + pub rot: Option, + pub columns: Option, + pub col_spacing: Option, + /// WordArt transform (`prstTxWarp`), e.g. `textArchUp`. + pub warp: Option, + pub rtl_col: Option, + /// Keep text upright when the shape rotates. + pub upright: Option, +} + +impl BodyProps { + pub fn inherit(&mut self, p: &BodyProps) { + self.inset_l = self.inset_l.or(p.inset_l); + self.inset_t = self.inset_t.or(p.inset_t); + self.inset_r = self.inset_r.or(p.inset_r); + self.inset_b = self.inset_b.or(p.inset_b); + self.anchor = self.anchor.or(p.anchor); + self.anchor_ctr = self.anchor_ctr.or(p.anchor_ctr); + self.wrap = self.wrap.or(p.wrap); + self.autofit = self.autofit.or(p.autofit); + self.vert = self.vert.or(p.vert); + self.rot = self.rot.or(p.rot); + self.columns = self.columns.or(p.columns); + self.col_spacing = self.col_spacing.or(p.col_spacing); + if self.warp.is_none() { + self.warp.clone_from(&p.warp); + } + self.rtl_col = self.rtl_col.or(p.rtl_col); + self.upright = self.upright.or(p.upright); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Align { + Left, + Center, + Right, + Justify, + Distributed, +} + +impl Align { + pub fn xml(self) -> &'static str { + match self { + Align::Left => "l", + Align::Center => "ctr", + Align::Right => "r", + Align::Justify => "just", + Align::Distributed => "dist", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "l" => Align::Left, + "ctr" => Align::Center, + "r" => Align::Right, + "just" | "justLow" | "thaiDist" => Align::Justify, + "dist" => Align::Distributed, + _ => return None, + }) + } +} + +/// Spacing as a percentage of the line (1.0 = single) or in points. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "v")] +pub enum Spacing { + Pct(f64), + Pts(f64), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Bullet { + None, + Char { + char: String, + }, + /// `arabicPeriod`, `arabicParenR`, `romanUcPeriod`, `romanLcPeriod`, `alphaUcPeriod`, + /// `alphaLcParenR`, `alphaLcPeriod`, `circleNumDbPlain`… + AutoNum { + scheme: String, + #[serde(default = "start_one")] + start_at: u32, + }, + Picture { + media: crate::MediaId, + }, +} + +fn start_one() -> u32 { + 1 +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + pub pos: f64, + /// `l`, `ctr`, `r`, `dec`. + pub align: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ParaProps { + pub align: Option, + /// Left margin (points). + pub margin_left: Option, + pub margin_right: Option, + /// First-line indent relative to the margin (negative = hanging). + pub indent: Option, + pub line_spacing: Option, + pub space_before: Option, + pub space_after: Option, + pub bullet: Option, + pub bullet_font: Option, + /// `None` = follow text colour. + pub bullet_color: Option, + /// Bullet size as a fraction of the text size. + pub bullet_size: Option, + pub tabs: Option>, + pub default_tab: Option, + pub rtl: Option, + pub font_align: Option, + pub east_asian_line_break: Option, +} + +impl ParaProps { + pub fn inherit(&mut self, p: &ParaProps) { + self.align = self.align.or(p.align); + self.margin_left = self.margin_left.or(p.margin_left); + self.margin_right = self.margin_right.or(p.margin_right); + self.indent = self.indent.or(p.indent); + self.line_spacing = self.line_spacing.or(p.line_spacing); + self.space_before = self.space_before.or(p.space_before); + self.space_after = self.space_after.or(p.space_after); + if self.bullet.is_none() { + self.bullet.clone_from(&p.bullet); + } + if self.bullet_font.is_none() { + self.bullet_font.clone_from(&p.bullet_font); + } + if self.bullet_color.is_none() { + self.bullet_color.clone_from(&p.bullet_color); + } + self.bullet_size = self.bullet_size.or(p.bullet_size); + if self.tabs.is_none() { + self.tabs.clone_from(&p.tabs); + } + self.default_tab = self.default_tab.or(p.default_tab); + self.rtl = self.rtl.or(p.rtl); + if self.font_align.is_none() { + self.font_align.clone_from(&p.font_align); + } + self.east_asian_line_break = self.east_asian_line_break.or(p.east_asian_line_break); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Caps { + None, + Small, + All, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Strike { + None, + Single, + Double, +} + +/// A hyperlink or action on text or a shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Action { + Url { url: String }, + Slide { slide: crate::SlideId }, + NextSlide, + PreviousSlide, + FirstSlide, + LastSlide, + EndShow, + LastViewed, + CustomShow { name: String }, + Program { path: String }, + PlayMedia, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Hyperlink { + pub action: Action, + #[serde(default)] + pub tooltip: String, + #[serde(default)] + pub highlight_click: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RunProps { + /// Latin font family, or a theme reference `+mj-lt` / `+mn-lt`. + pub font: Option, + pub font_ea: Option, + pub font_cs: Option, + pub font_sym: Option, + /// Size in points. + pub size: Option, + pub bold: Option, + pub italic: Option, + /// `none`, `sng`, `dbl`, `heavy`, `dotted`, `dash`, `wavy`… + pub underline: Option, + pub underline_color: Option, + pub strike: Option, + /// Baseline offset as a fraction of the size (0.3 = superscript 30%). + pub baseline: Option, + pub caps: Option, + /// Character spacing in points. + pub spacing: Option, + /// Kern at and above this size (points); 0 = off. + pub kern: Option, + pub fill: Option, + pub outline: Option, + pub highlight: Option, + pub link: Option, + pub lang: Option, + pub shadow: Option, + pub glow: Option, + /// Spelling/grammar errors are not flagged. + pub no_proof: Option, +} + +impl RunProps { + pub fn inherit(&mut self, p: &RunProps) { + macro_rules! take { + ($($f:ident),*) => { $( if self.$f.is_none() { self.$f.clone_from(&p.$f); } )* }; + } + take!( + font, + font_ea, + font_cs, + font_sym, + size, + bold, + italic, + underline, + underline_color, + strike, + baseline, + caps, + spacing, + kern, + fill, + outline, + highlight, + link, + lang, + shadow, + glow, + no_proof + ); + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum RunKind { + Text, + /// A line break inside the paragraph (Shift+Enter). + Break, + /// A field: `slidenum`, `datetime`, `datetime1`…`datetime13`, or other; `text` holds the last value. + Field { + field: String, + }, + /// An inline equation (OMML kept as XML; `text` is its linear form). + Math { + omml: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Run { + #[serde(default)] + pub text: String, + #[serde(default, skip_serializing_if = "is_default_props")] + pub props: RunProps, + #[serde(default = "text_kind", skip_serializing_if = "is_text_kind")] + pub kind: RunKind, +} + +fn is_default_props(p: &RunProps) -> bool { + *p == RunProps::default() +} +fn text_kind() -> RunKind { + RunKind::Text +} +fn is_text_kind(k: &RunKind) -> bool { + *k == RunKind::Text +} + +impl Run { + pub fn new(text: impl Into) -> Self { + Run { text: text.into(), props: RunProps::default(), kind: RunKind::Text } + } + pub fn with(text: impl Into, props: RunProps) -> Self { + Run { text: text.into(), props, kind: RunKind::Text } + } + /// The characters this run contributes to the paragraph (a break is one `\u{b}`). + pub fn content(&self) -> &str { + match self.kind { + RunKind::Break => "\u{b}", + _ => &self.text, + } + } + pub fn char_len(&self) -> usize { + self.content().chars().count() + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Paragraph { + /// Outline level 0–8. + pub level: u8, + #[serde(skip_serializing_if = "is_default_para")] + pub props: ParaProps, + pub runs: Vec, + /// Properties of the paragraph end mark (used for an empty paragraph's caret). + #[serde(skip_serializing_if = "is_default_props")] + pub end_props: RunProps, +} + +fn is_default_para(p: &ParaProps) -> bool { + *p == ParaProps::default() +} + +impl Paragraph { + pub fn new(text: &str) -> Self { + Paragraph { runs: if text.is_empty() { vec![] } else { vec![Run::new(text)] }, ..Default::default() } + } + pub fn text(&self) -> String { + self.runs.iter().map(|r| r.content()).collect() + } + pub fn char_len(&self) -> usize { + self.runs.iter().map(Run::char_len).sum() + } + pub fn is_empty(&self) -> bool { + self.char_len() == 0 + } + /// Merge adjacent text runs with identical properties. + pub fn normalize(&mut self) { + let mut out: Vec = Vec::with_capacity(self.runs.len()); + for r in self.runs.drain(..) { + if r.kind == RunKind::Text && r.text.is_empty() { + continue; + } + if let Some(last) = out.last_mut() + && last.kind == RunKind::Text + && r.kind == RunKind::Text + && last.props == r.props + { + last.text.push_str(&r.text); + continue; + } + out.push(r); + } + self.runs = out; + } +} + +/// Per-level paragraph + run defaults (`lstStyle`, master text styles, `defaultTextStyle`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LevelStyle { + pub para: ParaProps, + pub run: RunProps, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ListStyle { + /// Levels 1–9 (index 0–8); `None` = not specified. + pub levels: Vec>, +} + +impl ListStyle { + pub fn level(&self, l: u8) -> Option<&LevelStyle> { + self.levels.get(l as usize).and_then(|x| x.as_ref()) + } + pub fn set(&mut self, l: u8, s: LevelStyle) { + let i = (l as usize).min(8); + if self.levels.len() <= i { + self.levels.resize(i + 1, None); + } + if let Some(slot) = self.levels.get_mut(i) { + *slot = Some(s); + } + } + pub fn is_empty(&self) -> bool { + self.levels.iter().all(Option::is_none) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextBody { + pub body: BodyProps, + #[serde(skip_serializing_if = "ListStyle::is_empty")] + pub list_style: ListStyle, + pub paragraphs: Vec, +} + +impl TextBody { + pub fn from_text(text: &str) -> Self { + TextBody { paragraphs: text.split('\n').map(Paragraph::new).collect(), ..Default::default() } + } + /// Plain text with `\n` between paragraphs. + pub fn text(&self) -> String { + self.paragraphs.iter().map(Paragraph::text).collect::>().join("\n") + } + pub fn is_empty(&self) -> bool { + self.paragraphs.iter().all(Paragraph::is_empty) + } + /// Total characters, counting one separator between paragraphs. + pub fn char_len(&self) -> usize { + let n: usize = self.paragraphs.iter().map(Paragraph::char_len).sum(); + n + self.paragraphs.len().saturating_sub(1) + } +} diff --git a/crates/model/src/theme.rs b/crates/model/src/theme.rs new file mode 100644 index 0000000..3a6ec4f --- /dev/null +++ b/crates/model/src/theme.rs @@ -0,0 +1,251 @@ +//! Themes: colour scheme, font scheme and format scheme (the fill/line/effect "style matrix"). + +use serde::{Deserialize, Serialize}; +use slidecraft_color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; + +use crate::style::{ColorRef, Effects, Fill, Gradient, GradientShape, GradientStop, Line, Shadow}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FontSet { + pub latin: String, + pub ea: String, + pub cs: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FontScheme { + pub name: String, + /// Headings. + pub major: FontSet, + /// Body. + pub minor: FontSet, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FormatScheme { + pub name: String, + /// Subtle, moderate, intense (style index 1–3). + pub fills: Vec, + pub lines: Vec, + pub effects: Vec, + /// Background fills (index 1001–1003). + pub bg_fills: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Theme { + pub name: String, + pub colors: ColorScheme, + pub fonts: FontScheme, + pub format: FormatScheme, + /// Raw theme XML extras kept for round-trip (object defaults, extra colour lists). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_extra: Option, +} + +impl Default for Theme { + fn default() -> Self { + builtin_themes().into_iter().next().unwrap_or_else(|| Theme { + name: "SlideCraft".into(), + colors: ColorScheme { name: "SlideCraft".into(), colors: [Rgba::BLACK; 12] }, + fonts: FontScheme::default(), + format: FormatScheme::default(), + raw_extra: None, + }) + } +} + +impl Theme { + /// Resolve a theme font reference (`+mj-lt`, `+mn-ea`…) to a family name. + pub fn font(&self, name: &str) -> String { + match name { + "+mj-lt" => self.fonts.major.latin.clone(), + "+mn-lt" => self.fonts.minor.latin.clone(), + "+mj-ea" => self.fonts.major.ea.clone(), + "+mn-ea" => self.fonts.minor.ea.clone(), + "+mj-cs" => self.fonts.major.cs.clone(), + "+mn-cs" => self.fonts.minor.cs.clone(), + other => other.to_string(), + } + } +} + +fn hex(s: &str) -> Rgba { + Rgba::from_hex(s).unwrap_or(Rgba::BLACK) +} + +fn scheme(name: &str, c: [&str; 12]) -> ColorScheme { + ColorScheme { name: name.into(), colors: c.map(hex) } +} + +fn ph() -> ColorRef { + ColorRef::scheme(SchemeSlot::PhClr) +} + +/// Our default format scheme: flat solid fills, a soft gradient, and a gentle shadow for "intense". +pub fn default_format() -> FormatScheme { + let grad = |a: i32, b: i32| { + Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) }, + GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + }) + }; + FormatScheme { + name: "SlideCraft".into(), + fills: vec![Fill::solid(ph()), grad(110000, 92000), grad(105000, 80000)], + lines: vec![Line::solid(ph(), 0.75), Line::solid(ph(), 1.0), Line::solid(ph(), 1.5)], + effects: vec![ + Effects::default(), + Effects::default(), + Effects { + outer_shadow: Some(Shadow { + color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)), + blur: 6.0, + dist: 3.0, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "t".into(), + rotate_with_shape: false, + }), + ..Default::default() + }, + ], + bg_fills: vec![ + Fill::solid(ph()), + Fill::solid(ph().with(ColorTransform::Tint(95000)).with(ColorTransform::SatMod(170000))), + grad(102000, 85000), + ], + } +} + +/// Our original built-in themes (shown in Design ▸ Themes). Colours and names are our own. +pub fn builtin_themes() -> Vec { + let t = |name: &str, colors: ColorScheme, major: &str, minor: &str| Theme { + name: name.into(), + colors, + fonts: FontScheme { + name: name.into(), + major: FontSet { latin: major.into(), ..Default::default() }, + minor: FontSet { latin: minor.into(), ..Default::default() }, + }, + format: default_format(), + raw_extra: None, + }; + vec![ + t( + "SlideCraft", + scheme( + "SlideCraft", + ["000000", "FFFFFF", "1E2B3C", "EDEBE7", "2E6FD8", "F26B1D", "2EA36F", "8A5CD8", "D83F6B", "E3B21C", "2E6FD8", "7A5BA6"], + ), + "Inter", + "Inter", + ), + t( + "Harbor", + scheme( + "Harbor", + ["0B1F2A", "FFFFFF", "12384D", "E4EEF2", "1B7FA6", "36B3A8", "F2C14E", "F78154", "5D576B", "8AA29E", "1B7FA6", "5D576B"], + ), + "Merriweather", + "Open Sans", + ), + t( + "Ember", + scheme("Ember", ["1A1A1A", "FFFCF7", "3D2B1F", "F6EDE3", "D9480F", "F59F00", "A61E4D", "5C940D", "1971C2", "862E9C", "D9480F", "862E9C"]), + "Montserrat", + "Lato", + ), + t( + "Meadow", + scheme( + "Meadow", + ["14281D", "FFFFFF", "2D4A37", "EEF5EE", "4C9A2A", "A4C639", "2F6F4E", "E9B44C", "9B2915", "50A2A7", "2F6F4E", "9B2915"], + ), + "Merriweather", + "Open Sans", + ), + t( + "Nocturne", + scheme( + "Nocturne", + ["FFFFFF", "121826", "F2F4F8", "1E2638", "7C5CFF", "00C2A8", "FF6B9A", "FFC53D", "4DA3FF", "B2F35F", "7C5CFF", "FF6B9A"], + ), + "Inter", + "Inter", + ), + t( + "Paper", + scheme("Paper", ["262626", "FFFFFF", "404040", "F2F2F2", "595959", "8C8C8C", "C0504D", "4F81BD", "9BBB59", "F79646", "4F81BD", "8064A2"]), + "Playfair Display", + "Lato", + ), + t( + "Coral Reef", + scheme( + "Coral Reef", + ["0F2A33", "FFFFFF", "0E4D64", "FDF1EC", "FF6F59", "254441", "43AA8B", "B2B09B", "EF3054", "3F88C5", "3F88C5", "EF3054"], + ), + "Poppins", + "Nunito Sans", + ), + t( + "Slate", + scheme("Slate", ["111827", "FFFFFF", "374151", "F3F4F6", "6366F1", "0EA5E9", "10B981", "F59E0B", "EF4444", "8B5CF6", "6366F1", "8B5CF6"]), + "Roboto", + "Roboto", + ), + ] +} + +/// Named colour schemes for Design ▸ Variants ▸ Colors. +pub fn builtin_color_schemes() -> Vec { + let mut v: Vec = builtin_themes().into_iter().map(|t| t.colors).collect(); + v.push(scheme( + "Grayscale", + ["000000", "FFFFFF", "000000", "F8F8F8", "DDDDDD", "B2B2B2", "969696", "808080", "5F5F5F", "4D4D4D", "5F5F5F", "919191"], + )); + v.push(scheme("Ocean", ["000000", "FFFFFF", "13293D", "E8F1F2", "006494", "247BA0", "1B98E0", "6CC1E8", "13293D", "0B3954", "1B98E0", "6CC1E8"])); + v.push(scheme( + "Sunset", + ["2B2118", "FFFFFF", "6F1A07", "FFF3E6", "FF9F1C", "FFBF69", "CB997E", "E76F51", "F4A261", "2A9D8F", "E76F51", "2A9D8F"], + )); + v.push(scheme( + "Forest", + ["1B1B1B", "FFFFFF", "283618", "FEFAE0", "606C38", "DDA15E", "BC6C25", "283618", "8A9A5B", "4F772D", "606C38", "BC6C25"], + )); + v.push(scheme("Berry", ["1F0A1D", "FFFFFF", "4A1942", "F7E8F3", "893168", "C6426E", "EB6A8F", "4A1942", "7D7ABC", "56A3A6", "893168", "56A3A6"])); + v +} + +/// Named font pairs for Design ▸ Variants ▸ Fonts. +pub fn builtin_font_schemes() -> Vec { + let f = |name: &str, major: &str, minor: &str| FontScheme { + name: name.into(), + major: FontSet { latin: major.into(), ..Default::default() }, + minor: FontSet { latin: minor.into(), ..Default::default() }, + }; + vec![ + f("Inter", "Inter", "Inter"), + f("Classic", "Merriweather", "Open Sans"), + f("Geometric", "Montserrat", "Lato"), + f("Editorial", "Playfair Display", "Lato"), + f("Humanist", "Merriweather", "Open Sans"), + f("Friendly", "Poppins", "Nunito Sans"), + f("Neutral", "Roboto", "Roboto"), + f("Office Compatible", "Carlito", "Carlito"), + f("Times / Arial", "Liberation Serif", "Liberation Sans"), + ] +} diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml new file mode 100644 index 0000000..a62e190 --- /dev/null +++ b/crates/pptx/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "slidecraft-pptx" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft PPTX: Office Open XML (PresentationML/DrawingML) import and export, written from the public ECMA-376 standard" + +[lints] +workspace = true + +[dependencies] +slidecraft-model = { workspace = true } +slidecraft-geom = { workspace = true } +slidecraft-color = { workspace = true } +quick-xml = { workspace = true } +zip = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs new file mode 100644 index 0000000..4062f78 --- /dev/null +++ b/crates/pptx/src/lib.rs @@ -0,0 +1,27 @@ +//! PPTX import and export (in progress). +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use slidecraft_model::Presentation; + +#[derive(Debug, thiserror::Error)] +pub enum PptxError { + #[error("not a PPTX file: {0}")] + NotPptx(String), + #[error("unsupported: {0}")] + Unsupported(String), + #[error("write failed: {0}")] + Write(String), +} + +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"PK") && bytes.windows(19).take(4096).any(|w| w == b"[Content_Types].xml") +} + +pub fn import(_bytes: &[u8]) -> Result { + Err(PptxError::Unsupported("PPTX import is being implemented".into())) +} + +pub fn export(_p: &Presentation) -> Result, PptxError> { + Err(PptxError::Unsupported("PPTX export is being implemented".into())) +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 0000000..3dd4734 --- /dev/null +++ b/crates/render/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "slidecraft-render" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft renderer: slides, layouts and masters to pixels (vello_cpu): fills, outlines, effects, pictures, text, tables, charts" + +[lints] +workspace = true + +[dependencies] +slidecraft-geom = { workspace = true } +slidecraft-color = { workspace = true } +slidecraft-model = { workspace = true } +slidecraft-fonts = { workspace = true } +slidecraft-text = { workspace = true } +vello_cpu = { workspace = true } +vello_common = { workspace = true } +image = { workspace = true } +log = { workspace = true } +kurbo = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +vello_cpu = { workspace = true, features = ["multithreading"] } diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs new file mode 100644 index 0000000..059bb52 --- /dev/null +++ b/crates/render/src/chart.rs @@ -0,0 +1,394 @@ +//! Charts: title, plot area with axes and gridlines, series marks, legend. + +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use slidecraft_color::{ColorTransform, Rgba, SchemeSlot}; +use slidecraft_model::Shape; +use slidecraft_model::chart::{Chart, ChartType, nice_scale}; +use slidecraft_model::resolve::Ctx; +use slidecraft_model::style::{ColorRef, Fill}; +use slidecraft_model::text::{Align, Anchor, Bullet, TextBody}; +use vello_cpu::RenderContext; + +use crate::color; + +const SLOTS: [SchemeSlot; 6] = + [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6]; + +/// Colour of series/point `i`: accents, then darker and lighter rounds. +pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba { + let slot = SLOTS.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1); + let base = ctx.color(&ColorRef::scheme(slot), None); + let mono = c.palette.starts_with("monochrome"); + let base = if mono { ctx.color(&ColorRef::scheme(SchemeSlot::Accent1), None) } else { base }; + let round = if mono { i } else { i / 6 }; + match round { + 0 => base, + 1 => slidecraft_color::apply(base, &[ColorTransform::Shade(60000)]), + 2 => slidecraft_color::apply(base, &[ColorTransform::Tint(60000)]), + n => slidecraft_color::apply(base, &[ColorTransform::LumMod(100000 - (n as i32 % 5) * 12000)]), + } +} + +fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&slidecraft_model::chart::Series>) -> Rgba { + match s.and_then(|s| s.fill.as_ref()) { + Some(Fill::Solid { color: cc }) => ctx.color(cc, None), + _ => series_color(ctx, c, i), + } +} + +fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { + let mut body = TextBody::from_text(text); + body.body.inset_l = Some(0.0); + body.body.inset_r = Some(0.0); + body.body.inset_t = Some(0.0); + body.body.inset_b = Some(0.0); + body.body.anchor = Some(anchor); + body.body.wrap = Some(false); + for p in &mut body.paragraphs { + p.props.align = Some(align); + p.props.bullet = Some(Bullet::None); + p.props.margin_left = Some(0.0); + p.props.indent = Some(0.0); + for run in &mut p.runs { + run.props.size = Some(size); + run.props.bold = Some(bold); + run.props.fill = Some(Fill::solid(ColorRef::rgb(col))); + } + } + let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + crate::draw_text(ctx, rctx, &tmp, &body, r, m, &slidecraft_text::NoFields, None); +} + +fn fmt_num(v: f64) -> String { + if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") } +} + +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { + let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); + let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); + if let Some(f) = &c.chart_fill { + crate::paint::fill_path( + ctx, + rctx, + f, + None, + &Rect::new(0.0, 0.0, w, h).to_path(0.1), + Rect::new(0.0, 0.0, w, h), + m, + slidecraft_geom::preset::FillMode::Norm, + 1.0, + rctx.pres, + ); + } + let base = (h.min(w) / 22.0).clamp(7.0, 18.0); + let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); + if let Some(t) = &c.title { + label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + base * 1.8), base * 1.4, Align::Center, Anchor::Top, text_col, false); + area.y0 += base * 2.4; + } + let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); + // Legend at the bottom. + let legend_items: Vec<(String, Rgba)> = if pie { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() + } else { + c.series.iter().enumerate().map(|(i, s)| (s.name.clone(), series_fill(rctx, c, i, Some(s)))).collect() + }; + if c.legend.is_some() && !legend_items.is_empty() { + let ly = area.y1 - base * 1.4; + let item_w = (legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).sum::()).min(w * 0.9); + let mut x = (w - item_w) / 2.0; + for (name, col) in &legend_items { + ctx.set_transform(m); + ctx.set_paint(color(*col, 1.0)); + ctx.fill_rect(&Rect::new(x, ly + base * 0.3, x + base * 0.7, ly + base)); + let tw = name.chars().count() as f64 * base * 0.55 + base; + label(ctx, rctx, m, name, Rect::new(x + base, ly, x + base + tw, ly + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); + x += tw + base * 0.6; + } + area.y1 = ly - base * 0.5; + } + if pie { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + let total: f64 = vals.iter().sum(); + if total <= 0.0 { + return; + } + let r = (area.width().min(area.height()) / 2.0) * 0.95; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; + let mut p = BezPath::new(); + p.move_to(cen); + let arc = kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(arc.append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + let col = s + .point_fills + .iter() + .find(|(k, _)| *k == i) + .and_then(|(_, f)| if let Fill::Solid { color: cc } = f { Some(rctx.color(cc, None)) } else { None }) + .unwrap_or_else(|| series_color(rctx, c, i)); + ctx.set_paint(color(col, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.stroke_path(&p); + if c.data_labels { + let mid = a0 + sweep / 2.0; + let lp = Point::new(cen.x + r * 0.65 * mid.cos(), cen.y + r * 0.65 * mid.sin()); + label( + ctx, + rctx, + m, + &fmt_num(*v), + Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), + base, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + a0 += sweep; + } + if c.kind == ChartType::Doughnut { + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&kurbo::Circle::new(cen, r * c.hole_size.clamp(0.1, 0.9)).to_path(0.1)); + } + return; + } + let (lo, hi) = c.value_range(); + let (min, max, step) = nice_scale(lo.min(0.0), hi); + let horiz = c.kind.horizontal(); + // Axis label gutter. + let gutter = base * 3.0; + let plot = if horiz { + Rect::new(area.x0 + gutter * 1.5, area.y0, area.x1, area.y1 - base * 1.6) + } else { + Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.8) + }; + if let Some(f) = &c.plot_fill { + crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, slidecraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); + } + let span = (max - min).max(1e-9); + let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }; + // Gridlines and value labels. + let n_steps = ((span / step).round() as i64).clamp(1, 50); + for k in 0..=n_steps { + let v = min + k as f64 * step; + let pos = to_v(v); + let mut g = BezPath::new(); + if horiz { + g.move_to((pos, plot.y0)); + g.line_to((pos, plot.y1)); + label( + ctx, + rctx, + m, + &fmt_num(v), + Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5), + base, + Align::Center, + Anchor::Top, + text_col, + false, + ); + } else { + g.move_to((plot.x0, pos)); + g.line_to((plot.x1, pos)); + label( + ctx, + rctx, + m, + &fmt_num(v), + Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), + base, + Align::Right, + Anchor::Middle, + text_col, + false, + ); + } + if c.gridlines { + ctx.set_transform(m); + ctx.set_paint(color(grid_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&g); + } + } + let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); + let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; + // Category labels. + for (i, name) in c.categories.iter().enumerate() { + let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; + if horiz { + label( + ctx, + rctx, + m, + name, + Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base), + base, + Align::Right, + Anchor::Middle, + text_col, + false, + ); + } else { + label( + ctx, + rctx, + m, + name, + Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * 1.6), + base, + Align::Center, + Anchor::Top, + text_col, + false, + ); + } + } + let ns = c.series.len().max(1); + let zero = to_v(0.0f64.clamp(min, max)); + if c.kind.is_bar_like() { + let gap = c.gap_width.clamp(0.0, 5.0); + let group = band / (1.0 + gap); + let stacked = c.kind.stacked(); + let bar = if stacked { group } else { group / ns as f64 }; + for i in 0..ncat { + let mut pos_acc = 0.0; + let mut neg_acc = 0.0; + let total: f64 = if c.kind.percent() { + c.series.iter().map(|s| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::().max(1e-9) + } else { + 1.0 + }; + for (si, s) in c.series.iter().enumerate() { + let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; + let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * si as f64 }; + let (a, b) = if stacked { + if v >= 0.0 { + let r = (pos_acc, pos_acc + v); + pos_acc += v; + r + } else { + let r = (neg_acc + v, neg_acc); + neg_acc += v; + r + } + } else { + (0.0f64.min(v), 0.0f64.max(v)) + }; + let r = if horiz { + Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar) + } else { + Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a)) + }; + ctx.set_transform(m); + ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0)); + ctx.fill_rect(&r.abs()); + if c.data_labels { + let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0)); + let lr = if horiz { + Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1) + } else { + Rect::new(r.x0 - 10.0, r.y0 - base * 1.4, r.x1 + 10.0, r.y0) + }; + label( + ctx, + rctx, + m, + &t, + lr, + base * 0.9, + if horiz { Align::Left } else { Align::Center }, + if horiz { Anchor::Middle } else { Anchor::Bottom }, + text_col, + false, + ); + } + } + } + } else { + // Lines, areas, scatter, radar (drawn as lines). + let stacked = c.kind.stacked(); + let mut acc = vec![0.0; ncat]; + for (si, s) in c.series.iter().enumerate() { + let col = series_fill(rctx, c, si, Some(s)); + let mut pts = vec![]; + for i in 0..ncat { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let v = if stacked { + let base_v = acc.get(i).copied().unwrap_or(0.0); + if let Some(a) = acc.get_mut(i) { + *a += v; + } + base_v + v + } else { + v + }; + let x = match c.kind { + ChartType::Scatter | ChartType::Bubble => { + let xs: Vec = s.x_values.iter().flatten().copied().collect(); + let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); + let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); + if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } + } + _ => plot.x0 + band * (i as f64 + 0.5), + }; + pts.push(Point::new(x, to_v(v))); + } + if pts.is_empty() { + continue; + } + let mut p = BezPath::new(); + for (k, q) in pts.iter().enumerate() { + if k == 0 { + p.move_to(*q); + } else { + p.line_to(*q); + } + } + ctx.set_transform(m); + if matches!(c.kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar) { + let mut a = p.clone(); + if let (Some(l), Some(f)) = (pts.last(), pts.first()) { + a.line_to((l.x, zero)); + a.line_to((f.x, zero)); + a.close_path(); + } + ctx.set_paint(color(col, 0.85)); + ctx.fill_path(&a); + } else if c.kind != ChartType::Scatter { + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.25).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&p); + } + if matches!(c.kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) { + ctx.set_paint(color(col, 1.0)); + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } + } + } + } + // Axis line. + let mut ax = BezPath::new(); + if horiz { + ax.move_to((zero, plot.y0)); + ax.line_to((zero, plot.y1)); + } else { + ax.move_to((plot.x0, zero)); + ax.line_to((plot.x1, zero)); + } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xBF, 0xBF, 0xBF), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&ax); +} diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs new file mode 100644 index 0000000..0359765 --- /dev/null +++ b/crates/render/src/images.rs @@ -0,0 +1,154 @@ +//! Decoded pictures shared by every render on this process, keyed by the media bytes' address and +//! the requested size level and adjustments, so each picture is decoded once per size. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use slidecraft_model::style::PictureAdjust; +use vello_cpu::Pixmap; + +const BUDGET: usize = 512 << 20; + +struct Entry { + _bytes: Arc>, + pm: Arc, + stamp: u64, +} + +#[derive(Default)] +struct Cache { + map: HashMap<(usize, u32, u64), Entry>, + bytes: usize, + clock: u64, + bad: HashMap>>, +} + +static CACHE: Mutex> = Mutex::new(None); + +fn with(f: impl FnOnce(&mut Cache) -> R) -> R { + let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + f(g.get_or_insert_with(Cache::default)) +} + +fn adjust_key(a: &PictureAdjust) -> u64 { + let mut h: u64 = 1469598103934665603; + let mut mix = |v: u64| { + h ^= v; + h = h.wrapping_mul(1099511628211); + }; + mix(a.brightness.to_bits()); + mix(a.contrast.to_bits()); + mix(a.saturation.unwrap_or(1.0).to_bits()); + mix(a.grayscale as u64); + mix(a.sharpen.to_bits()); + if let Some(c) = a.clear_color { + mix(((c.r as u64) << 16) | ((c.g as u64) << 8) | c.b as u64 | 1 << 40); + } + h +} + +/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA. +pub fn decode(bytes: &[u8]) -> Option { + if bytes.len() > 512 << 20 { + return None; + } + let img = image::load_from_memory(bytes).ok()?; + let (w, h) = (img.width(), img.height()); + if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 { + return None; + } + Some(img.to_rgba8()) +} + +/// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. +pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Option> { + let addr = Arc::as_ptr(bytes) as usize; + if with(|c| c.bad.contains_key(&addr)) { + return None; + } + // Power-of-two size buckets so zooming doesn't re-decode every frame. + let bucket = (want.max(16.0).log2().ceil() as u32).min(15); + let key = (addr, bucket, adjust_key(adj)); + if let Some(pm) = with(|c| { + c.clock += 1; + let clock = c.clock; + c.map.get_mut(&key).map(|e| { + e.stamp = clock; + e.pm.clone() + }) + }) { + return Some(pm); + } + let Some(mut img) = decode(bytes) else { + with(|c| { + c.bad.insert(addr, bytes.clone()); + }); + return None; + }; + let target = 1u32 << bucket; + let (w, h) = img.dimensions(); + if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) { + let k = target as f64 / w.max(h) as f64; + let (nw, nh) = (((w as f64 * k).round() as u32).max(1), ((h as f64 * k).round() as u32).max(1)); + img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle); + } + apply_adjust(&mut img, adj); + let (w, h) = img.dimensions(); + if w > u16::MAX as u32 || h > u16::MAX as u32 { + return None; + } + let data: Vec = img + .pixels() + .map(|p| { + let a = p[3] as u16; + let m = |c: u8| ((c as u16 * a + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } + }) + .collect(); + let pm = Arc::new(Pixmap::from_parts(data, w as u16, h as u16)); + with(|c| { + c.clock += 1; + let n = w as usize * h as usize * 4; + c.bytes += n; + c.map.insert(key, Entry { _bytes: bytes.clone(), pm: pm.clone(), stamp: c.clock }); + while c.bytes > BUDGET && c.map.len() > 1 { + let Some(oldest) = c.map.iter().min_by_key(|(_, e)| e.stamp).map(|(k, _)| *k) else { break }; + if let Some(e) = c.map.remove(&oldest) { + c.bytes = c.bytes.saturating_sub(e.pm.width() as usize * e.pm.height() as usize * 4); + } + } + }); + Some(pm) +} + +fn apply_adjust(img: &mut image::RgbaImage, a: &PictureAdjust) { + let sat = a.saturation.unwrap_or(1.0); + if a.brightness == 0.0 && a.contrast == 0.0 && sat == 1.0 && !a.grayscale && a.clear_color.is_none() && a.duotone.is_none() { + return; + } + let b = a.brightness.clamp(-1.0, 1.0) * 255.0; + let c = a.contrast.clamp(-1.0, 1.0); + let cf = if c >= 0.0 { 1.0 / (1.0 - c * 0.99) } else { 1.0 + c }; + for p in img.pixels_mut() { + if let Some(cc) = a.clear_color + && (p[0] as i32 - cc.r as i32).abs() < 8 + && (p[1] as i32 - cc.g as i32).abs() < 8 + && (p[2] as i32 - cc.b as i32).abs() < 8 + { + p[3] = 0; + continue; + } + let mut rgb = [p[0] as f64, p[1] as f64, p[2] as f64]; + let y = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + let s = if a.grayscale { 0.0 } else { sat }; + for v in &mut rgb { + *v = y + (*v - y) * s; + *v = (*v - 128.0) * cf + 128.0 + b; + } + for (i, v) in rgb.iter().enumerate() { + if let Some(ch) = p.0.get_mut(i) { + *ch = v.round().clamp(0.0, 255.0) as u8; + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 0000000..5c5be46 --- /dev/null +++ b/crates/render/src/lib.rs @@ -0,0 +1,947 @@ +//! The SlideCraft renderer: draws slides, layouts and masters with `vello_cpu`. +//! +//! One code path serves the editor canvas, thumbnails, image/PDF-raster export and the slide show: +//! [`render_slide`] draws the background, the master and layout graphics, then the slide's shapes +//! (fills with theme shading, gradients, pictures, outlines with dashes and arrowheads, shadows, +//! glow and soft edges, text, tables, charts, groups), optionally with per-shape animation state. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod chart; +mod images; +mod paint; +mod table; + +use std::collections::HashMap; +use std::sync::Arc; + +use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2}; +use slidecraft_color::Rgba; +use slidecraft_fonts::FontDb; +use slidecraft_geom::preset::{self, FillMode, Geometry, SubPath}; +use slidecraft_model::resolve::{self, Ctx}; +use slidecraft_model::style::{Effects, Fill, Line}; +use slidecraft_model::text::TextBody; +use slidecraft_model::{Geom, PhType, Presentation, Shape, ShapeId, ShapeKind, Slide}; +use slidecraft_text::{Fields, Opts}; +use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use vello_cpu::{RenderContext, Resources, peniko}; + +pub use images::decode as decode_image; + +/// Largest pixmap side we render (vello_cpu uses u16 sizes). +pub const MAX_SIDE: u32 = 16_000; + +/// A rendered image: premultiplied RGBA8. +#[derive(Clone, Debug, Default)] +pub struct Image { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Image { + /// Straight (unpremultiplied) RGBA. + pub fn to_straight(&self) -> Vec { + let mut out = self.pixels.clone(); + for px in out.as_chunks_mut::<4>().0 { + let a = px.get(3).copied().unwrap_or(255) as u32; + if a != 0 && a != 255 { + for c in px.iter_mut().take(3) { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + out + } + pub fn to_png(&self) -> Vec { + let mut px = self.to_straight(); + px.resize(self.width as usize * self.height as usize * 4, 0); + let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { return Vec::new() }; + let mut buf = Vec::new(); + if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) { + log::error!("PNG encode: {e}"); + return Vec::new(); + } + buf + } + pub fn to_jpeg(&self, quality: u8) -> Vec { + let straight = self.to_straight(); + let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; + let mut buf = Vec::new(); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + if let Err(e) = img.write_with_encoder(enc) { + log::error!("JPEG encode: {e}"); + return Vec::new(); + } + buf + } + /// Pixel at (x, y) as straight RGBA. + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = (y as usize * self.width as usize + x as usize) * 4; + let p = self.pixels.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]); + let a = p.get(3).copied().unwrap_or(0) as u32; + let un = |c: u8| (c as u32 * 255 + a / 2).checked_div(a).map(|v| v.min(255) as u8).unwrap_or(0); + [un(p[0]), un(p[1]), un(p[2]), a as u8] + } +} + +/// Animation state of one shape during a slide show or preview. +#[derive(Clone, Debug)] +pub struct ShapeState { + pub visible: bool, + pub opacity: f64, + /// Applied in slide space around the shape's centre: translation (pt), scale, rotation (deg). + pub offset: Vec2, + pub scale: (f64, f64), + pub rotate: f64, + /// Reveal clip in shape-local unit space (0..1): l, t, r, b. + pub clip: Option<[f64; 4]>, + /// Colour override for emphasis effects (fill). + pub tint: Option, +} + +impl Default for ShapeState { + fn default() -> Self { + ShapeState { visible: true, opacity: 1.0, offset: Vec2::ZERO, scale: (1.0, 1.0), rotate: 0.0, clip: None, tint: None } + } +} + +pub struct RenderOpts<'a> { + /// Pixels per point. + pub scale: f64, + /// Editor view: placeholder prompts and dashed placeholder outlines. + pub edit: bool, + /// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does. + pub show_hidden: bool, + /// Per-shape animation state. + pub state: Option<&'a dyn Fn(ShapeId) -> ShapeState>, + /// Don't draw these shapes (being edited in place, drawn by the UI). + pub skip: &'a [ShapeId], + /// Hide the text of this shape (the UI draws it while editing). + pub skip_text: Option, + /// Worker threads (0 = this thread; required when effects use filters). + pub threads: u16, + /// Translate the slide within the output (pixels) and output size override. + pub offset: (f64, f64), + pub size: Option<(u32, u32)>, + /// Draw a background colour outside the slide (output larger than slide). + pub clear: Option, + pub date_text: Option, +} + +impl Default for RenderOpts<'_> { + fn default() -> Self { + RenderOpts { + scale: 1.0, + edit: false, + show_hidden: false, + state: None, + skip: &[], + skip_text: None, + threads: 0, + offset: (0.0, 0.0), + size: None, + clear: None, + date_text: None, + } + } +} + +struct SlideFields { + num: u32, + date: String, + footer: String, +} + +impl Fields for SlideFields { + fn field(&self, kind: &str) -> Option { + if kind == "slidenum" { + return Some(self.num.to_string()); + } + if kind.starts_with("datetime") { + return Some(self.date.clone()); + } + if kind == "footer" { + return Some(self.footer.clone()); + } + None + } +} + +pub struct Renderer { + resources: Resources, +} + +impl Default for Renderer { + fn default() -> Self { + Renderer { resources: Resources::new() } + } +} + +thread_local! { + static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); +} + +/// Render slide `index` at `opts.scale` pixels per point. +pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { + let Some(slide) = pres.slides.get(index) else { return Image::default() }; + RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)) +} + +/// Render a layout (Slide Master view thumbnail and canvas). +pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { + RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)) +} + +fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) { + let (w, h) = opts + .size + .unwrap_or(((pres.slide_size.width * opts.scale).round().max(1.0) as u32, (pres.slide_size.height * opts.scale).round().max(1.0) as u32)); + (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16) +} + +pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color { + let a = (c.a as f64 * alpha.clamp(0.0, 1.0)).round().clamp(0.0, 255.0) as u8; + peniko::Color::from_rgba8(c.r, c.g, c.b, a) +} + +fn shade(c: Rgba, m: FillMode) -> Rgba { + match m { + FillMode::Lighten => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.4), + FillMode::LightenLess => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.2), + FillMode::Darken => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.4), + FillMode::DarkenLess => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.2), + _ => c, + } +} + +/// Geometry of a shape at its size. +pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry { + match &shape.geom { + Geom::Preset { name, adj } => preset::build(name, w, h, adj).or_else(|| preset::build("rect", w, h, &[])).unwrap_or(Geometry { + paths: vec![], + text_rect: Rect::new(0.0, 0.0, w, h), + handles: vec![], + sites: vec![], + }), + Geom::Custom { paths } => { + let mut out = vec![]; + for p in paths { + let (pw, ph) = (if p.w > 0.0 { p.w } else { w.max(1e-9) }, if p.h > 0.0 { p.h } else { h.max(1e-9) }); + let mut bp = parse_path(&p.d); + bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph)); + out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: false }); + } + Geometry { paths: out, text_rect: Rect::new(0.0, 0.0, w, h), handles: vec![], sites: vec![] } + } + } +} + +/// Parse our custom path syntax (`M x y L x y C … Q … Z`). +pub fn parse_path(d: &str) -> BezPath { + let mut bp = BezPath::new(); + let toks: Vec<&str> = d.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).collect(); + let mut i = 0; + let num = |i: usize| toks.get(i).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()); + let mut started = false; + while i < toks.len() { + let t = toks.get(i).copied().unwrap_or(""); + match t { + "M" => { + if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) { + bp.move_to((x, y)); + started = true; + } + i += 3; + } + "L" => { + if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) { + if !started { + bp.move_to((x, y)); + started = true; + } else { + bp.line_to((x, y)); + } + } + i += 3; + } + "C" => { + if let (Some(a), Some(b), Some(c), Some(d2), Some(e), Some(f)) = + (num(i + 1), num(i + 2), num(i + 3), num(i + 4), num(i + 5), num(i + 6)) + && started + { + bp.curve_to((a, b), (c, d2), (e, f)); + } + i += 7; + } + "Q" => { + if let (Some(a), Some(b), Some(c), Some(d2)) = (num(i + 1), num(i + 2), num(i + 3), num(i + 4)) + && started + { + bp.quad_to((a, b), (c, d2)); + } + i += 5; + } + "Z" | "z" => { + if started { + bp.close_path(); + } + i += 1; + } + _ => i += 1, + } + } + bp +} + +/// The rectangle a picture's image occupies given its crop (fractions; negative = padding). +pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(-10.0, 0.99) } else { 0.0 }); + let vis_w = (1.0 - l - r).max(1e-6); + let vis_h = (1.0 - t - b).max(1e-6); + let fw = bounds.width() / vis_w; + let fh = bounds.height() / vis_h; + Rect::new(bounds.x0 - l * fw, bounds.y0 - t * fh, bounds.x0 - l * fw + fw, bounds.y0 - t * fh + fh) +} + +struct Frame<'a> { + pres: &'a Presentation, + opts: &'a RenderOpts<'a>, + fields: SlideFields, +} + +impl Renderer { + fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { + let (w, h) = output_size(pres, opts); + let mut ctx = RenderContext::new_with( + w, + h, + vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") { 0 } else { opts.threads }, ..Default::default() }, + ); + if let Some(c) = opts.clear { + ctx.set_paint(color(c, 1.0)); + ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + } + let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); + let fields = SlideFields { + num: pres.slide_number(index), + date: opts.date_text.clone().unwrap_or_else(|| pres.header_footer.date_text.clone()), + footer: pres.header_footer.footer_text.clone(), + }; + let f = Frame { pres, opts, fields }; + if let Some(rctx) = Ctx::for_slide(pres, slide) { + self.background(&mut ctx, &f, &rctx, Some(slide), view); + let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); + if show_master { + let mctx = Ctx::for_master(pres, rctx.master); + for s in &rctx.master.shapes { + if s.ph.is_none() { + self.shape(&mut ctx, &f, &mctx, s, view, 0); + } + } + } + if show_layout && let Some(l) = rctx.layout { + let lctx = Ctx::for_layout(pres, rctx.master, l); + for s in &l.shapes { + if s.ph.is_none() { + self.shape(&mut ctx, &f, &lctx, s, view, 0); + } + } + } + for s in &slide.shapes { + self.shape(&mut ctx, &f, &rctx, s, view, 0); + } + } + self.finish(ctx, w, h) + } + + fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { + let (w, h) = output_size(pres, opts); + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale); + let f = Frame { pres, opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } }; + if let Some(m) = pres.masters.get(mi) { + match li.and_then(|i| m.layouts.get(i)) { + Some(l) => { + let c = Ctx::for_layout(pres, m, l); + self.background(&mut ctx, &f, &c, None, view); + if l.show_master_shapes { + let mc = Ctx::for_master(pres, m); + for s in m.shapes.iter().filter(|s| s.ph.is_none()) { + self.shape(&mut ctx, &f, &mc, s, view, 0); + } + } + for s in &l.shapes { + self.shape(&mut ctx, &f, &c, s, view, 0); + } + } + None => { + let c = Ctx::for_master(pres, m); + self.background(&mut ctx, &f, &c, None, view); + for s in &m.shapes { + self.shape(&mut ctx, &f, &c, s, view, 0); + } + } + } + } + self.finish(ctx, w, h) + } + + fn finish(&mut self, mut ctx: RenderContext, w: u16, h: u16) -> Image { + ctx.flush(); + let mut pixels = vec![0u8; w as usize * h as usize * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) { + ctx.render(pm, &mut self.resources); + } + Image { width: w as u32, height: h as u32, pixels } + } + + fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) { + let (fill, ph) = resolve::background(rctx, slide); + let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); + ctx.set_transform(view); + let path = r.to_path(0.1); + paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); + } + + fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) { + return; + } + // Footer placeholders on layouts/masters show only on slides that include them. + let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default(); + if !st.visible || st.opacity <= 0.001 { + return; + } + let x = resolve::xfrm(rctx, s); + if !x.is_finite() { + return; + } + let mut m = parent * x.affine(); + if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 { + let c = Point::new(x.w / 2.0, x.h / 2.0); + let around = Affine::translate(c.to_vec2()) + * Affine::rotate(st.rotate.to_radians()) + * Affine::scale_non_uniform(st.scale.0, st.scale.1) + * Affine::translate(-c.to_vec2()); + m = parent * Affine::translate(st.offset) * x.affine() * around; + } + let layered = st.opacity < 0.999 || st.clip.is_some(); + if layered { + ctx.set_transform(m); + let clip = st.clip.map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1)); + ctx.push_layer(clip.as_ref(), None, Some(st.opacity.clamp(0.0, 1.0) as f32), None, None); + } + match &s.kind { + ShapeKind::Group { children, child } => { + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let gm = parent * slidecraft_geom::group_child_affine(&x, ch); + for c in children { + self.shape(ctx, f, rctx, c, gm, depth + 1); + } + } + ShapeKind::Table(t) => table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields), + ShapeKind::Chart(c) => chart::draw(ctx, rctx, c, m, x.w, x.h), + ShapeKind::Ink { strokes } => { + for sk in strokes { + let mut bp = BezPath::new(); + for (i, (px, py, _)) in sk.points.iter().enumerate() { + if i == 0 { + bp.move_to((*px, *py)); + } else { + bp.line_to((*px, *py)); + } + } + ctx.set_transform(parent); + ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); + ctx.set_stroke(kurbo::Stroke::new(sk.width.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } + } + _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st), + } + if layered { + ctx.pop_layer(); + } + } + + fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) { + let geo = shape_geometry(s, w, h); + let (fill, fill_ph) = resolve::fill(rctx, s); + let (line, line_ph) = resolve::line(rctx, s); + let (effects, fx_ph) = resolve::effects(rctx, s); + let is_pic = matches!(s.kind, ShapeKind::Picture { .. }); + let outline = geo.outline(); + // Placeholders in the editor: dashed outline and prompt, nothing else when empty. + let empty_ph = s.ph.is_some() && s.text.as_ref().is_none_or(TextBody::is_empty) && !is_pic && !matches!(s.kind, ShapeKind::Media(_)); + let fill = if let Some(t) = st.tint { Some(Fill::solid(slidecraft_model::ColorRef::rgb(t))) } else { fill }; + // Effects below the shape. + if let Some(e) = &effects { + self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h); + } + let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); + if let Some(r) = soft { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, None, None, None); + let _ = r; + } + // Fill. + match &s.kind { + ShapeKind::Picture { fill: pf } => { + ctx.set_transform(m); + let bounds = Rect::new(0.0, 0.0, w, h); + ctx.push_clip_layer(&outline); + paint::picture(ctx, f.pres, pf, bounds, m); + ctx.pop_layer(); + } + ShapeKind::Media(mc) => { + ctx.set_transform(m); + let bounds = Rect::new(0.0, 0.0, w, h); + if let Some(p) = mc.poster { + let pf = slidecraft_model::style::PictureFill { media: p, ..Default::default() }; + paint::picture(ctx, f.pres, &pf, bounds, m); + } else if mc.video { + ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255)); + ctx.fill_rect(&bounds); + } + if !mc.video || mc.poster.is_none() { + paint::media_glyph(ctx, mc.video, bounds); + } + } + _ => { + if let Some(fl) = &fill + && !(empty_ph && f.opts.edit && s.fill.is_none()) + { + for sp in &geo.paths { + if sp.fill == FillMode::None { + continue; + } + ctx.set_transform(m); + ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, 1.0, f.pres); + ctx.set_fill_rule(peniko::Fill::NonZero); + } + } + } + } + // Outline. + if line.fill.as_ref().is_some_and(|f| !f.is_none()) { + let lw = line.width.unwrap_or(0.75).max(0.0); + for sp in geo.paths.iter().filter(|p| p.stroke) { + stroke(ctx, rctx, &line, line_ph, &sp.path, m, lw); + } + if s.is_line() || geo.is_open() { + arrowheads(ctx, rctx, &line, line_ph, &geo, m, lw); + } + } + if let Some(r) = soft { + // Feather: fade the shape's alpha towards the outline. + ctx.set_transform(m); + ctx.push_layer( + None, + Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), + None, + None, + Some(blur(r * 0.5 * scale_of(m))), + ); + ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6); + ctx.fill_rect(&inset); + ctx.pop_layer(); + ctx.pop_layer(); + } + // Inner shadow. + if let Some(e) = &effects + && let Some(sh) = &e.inner_shadow + { + ctx.set_transform(m); + ctx.push_clip_layer(&outline); + let c = rctx.color(&sh.color, fx_ph); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m)))); + ctx.set_paint(color(c, 1.0)); + let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1); + let mut hole = outline.clone(); + hole.apply_affine(Affine::translate(d)); + ring.extend(hole.iter()); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.fill_path(&ring); + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.pop_layer(); + ctx.pop_layer(); + } + // Text (or the placeholder prompt in the editor). + let skip_text = f.opts.skip_text == Some(s.id); + if let Some(body) = &s.text + && !skip_text + { + let tr = geo.text_rect; + if !body.is_empty() { + draw_text(ctx, rctx, s, body, tr, m, &f.fields, None); + } else if f.opts.edit && s.ph.is_some() { + let kind = s.ph_type().unwrap_or(PhType::Body); + let prompt_body = prompt_body(rctx, s, kind); + if !prompt_body.is_empty() { + let grey = Rgba::rgb(0x59, 0x59, 0x59); + draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); + } + } + } + if empty_ph && f.opts.edit { + // Dashed placeholder outline. + ctx.set_transform(m); + let px = 1.0 / scale_of(m).max(1e-6); + ctx.set_paint(peniko::Color::from_rgba8(0xA6, 0xA6, 0xA6, 255)); + ctx.set_stroke(kurbo::Stroke::new(px).with_dashes(0.0, [3.0 * px, 2.0 * px])); + ctx.stroke_path(&Rect::new(0.0, 0.0, w, h).to_path(0.1)); + if matches!(s.ph_type(), Some(PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram)) { + paint::placeholder_icon(ctx, s.ph_type().unwrap_or(PhType::Picture), Rect::new(0.0, 0.0, w, h)); + } + } + } + + fn effects_below( + &mut self, + ctx: &mut RenderContext, + rctx: &Ctx, + e: &Effects, + ph: Option, + geo: &Geometry, + fill: &Option, + m: Affine, + w: f64, + h: f64, + ) { + let silhouette = { + let mut p = BezPath::new(); + for sp in &geo.paths { + if sp.fill != FillMode::None || fill.is_none() { + p.extend(sp.path.iter()); + } + } + if p.elements().is_empty() { geo.outline() } else { p } + }; + let k = scale_of(m); + if let Some(sh) = &e.outer_shadow { + let c = rctx.color(&sh.color, ph); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + let c0 = Point::new(w / 2.0, h / 2.0); + let local = Affine::translate(c0.to_vec2()) + * Affine::new([sh.sx, sh.ky.to_radians().tan(), sh.kx.to_radians().tan(), sh.sy, 0.0, 0.0]) + * Affine::translate(-c0.to_vec2()); + // Shadow offset is in slide space (doesn't rotate with the shape unless asked). + let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local }; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k))); + ctx.set_transform(sm); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.fill_path(&silhouette); + ctx.pop_layer(); + } + if let Some(g) = &e.glow { + let c = rctx.color(&g.color, ph); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k))); + ctx.set_transform(m); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round)); + ctx.stroke_path(&silhouette); + ctx.fill_path(&silhouette); + ctx.pop_layer(); + } + if let Some(rf) = &e.reflection { + // Mirror below the shape, fading out. + let mm = m * Affine::translate((0.0, 2.0 * h + rf.dist)) * Affine::scale_non_uniform(1.0, -1.0); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6), None, Some(blur(rf.blur * 0.5 * k))); + ctx.set_transform(mm); + if let Some(fl) = fill { + paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres); + } + // Fade mask. + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, None); + let grad = peniko::Gradient::new_linear(Point::new(0.0, h), Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0)))) + .with_stops([(0.0, peniko::Color::from_rgba8(0, 0, 0, 255)), (1.0, peniko::Color::from_rgba8(0, 0, 0, 0))].as_slice()); + ctx.set_paint(grad); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(4.0, 4.0)); + ctx.pop_layer(); + ctx.pop_layer(); + } + } +} + +fn pre_translate(m: Affine, d: Vec2) -> Affine { + Affine::translate(d) * m +} + +pub(crate) fn scale_of(m: Affine) -> f64 { + m.determinant().abs().sqrt() +} + +fn blur(sigma: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) +} + +fn stroke(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, path: &BezPath, m: Affine, lw: f64) { + use slidecraft_model::style::{LineCap, LineJoin}; + let cap = match line.cap.unwrap_or_default() { + LineCap::Flat => kurbo::Cap::Butt, + LineCap::Round => kurbo::Cap::Round, + LineCap::Square => kurbo::Cap::Square, + }; + let join = match line.join.unwrap_or_default() { + LineJoin::Round => kurbo::Join::Round, + LineJoin::Bevel => kurbo::Join::Bevel, + LineJoin::Miter => kurbo::Join::Miter, + }; + // Hairlines stay visible at any zoom. + let min_w = 0.5 / scale_of(m).max(1e-6); + let mut sk = kurbo::Stroke::new(lw.max(min_w)).with_caps(cap).with_join(join); + if let Some(d) = &line.dash { + let pat: Vec = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); + if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) { + sk = sk.with_dashes(0.0, pat); + } + } + ctx.set_transform(m); + match &line.fill { + Some(Fill::Solid { color: c }) => { + ctx.set_paint(color(rctx.color(c, ph), 1.0)); + } + Some(Fill::Gradient(g)) => { + let c = g.stops.first().map(|s| rctx.color(&s.color, ph)).unwrap_or(Rgba::BLACK); + ctx.set_paint(color(c, 1.0)); + } + _ => ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)), + } + ctx.set_stroke(sk); + ctx.stroke_path(path); +} + +fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { + let els: Vec = path.elements().to_vec(); + let mut pts: Vec = vec![]; + for e in &els { + match e { + PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(*p), + PathEl::QuadTo(a, b) => { + pts.push(*a); + pts.push(*b); + } + PathEl::CurveTo(a, b, c) => { + pts.push(*a); + pts.push(*b); + pts.push(*c); + } + PathEl::ClosePath => {} + } + } + if pts.len() < 2 { + return None; + } + let first = *pts.first()?; + let second = pts.iter().skip(1).find(|p| (**p - first).hypot() > 1e-6).copied().unwrap_or(*pts.get(1)?); + let last = *pts.last()?; + let prev = pts.iter().rev().skip(1).find(|p| (**p - last).hypot() > 1e-6).copied().unwrap_or(*pts.get(pts.len() - 2)?); + Some(((first, second), (last, prev))) +} + +fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, m: Affine, lw: f64) { + let Some(sp) = geo.paths.iter().find(|p| p.stroke) else { return }; + let Some(((a, a2), (b, b2))) = end_points(&sp.path) else { return }; + let col = match &line.fill { + Some(Fill::Solid { color: c }) => rctx.color(c, ph), + _ => Rgba::BLACK, + }; + let lw = lw.max(0.75); + for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] { + let Some(e) = end else { continue }; + if e.kind == "none" { + continue; + } + let sz = |s: &str| match s { + "sm" => 2.0, + "lg" => 5.0, + _ => 3.0, + }; + let (wl, ll) = (sz(&e.w) * lw, sz(&e.len) * lw); + let dir = (tip - from).normalize(); + if !dir.x.is_finite() { + continue; + } + let n = Vec2::new(-dir.y, dir.x); + let base = tip - dir * ll; + let mut p = BezPath::new(); + match e.kind.as_str() { + "oval" => { + let c = kurbo::Ellipse::new(tip, (ll / 2.0, wl / 2.0), dir.y.atan2(dir.x)); + p = c.to_path(0.05); + } + "diamond" => { + p.move_to(tip + dir * ll / 2.0); + p.line_to(tip + n * wl / 2.0); + p.line_to(tip - dir * ll / 2.0); + p.line_to(tip - n * wl / 2.0); + p.close_path(); + } + "stealth" => { + p.move_to(tip); + p.line_to(base + n * wl / 2.0); + p.line_to(tip - dir * ll * 0.6); + p.line_to(base - n * wl / 2.0); + p.close_path(); + } + "arrow" => { + let mut o = BezPath::new(); + o.move_to(base + n * wl / 2.0); + o.line_to(tip); + o.line_to(base - n * wl / 2.0); + ctx.set_transform(m); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Miter)); + ctx.stroke_path(&o); + continue; + } + _ => { + p.move_to(tip); + p.line_to(base + n * wl / 2.0); + p.line_to(base - n * wl / 2.0); + p.close_path(); + } + } + ctx.set_transform(m); + ctx.set_paint(color(col, 1.0)); + ctx.fill_path(&p); + } +} + +/// The prompt shown in an empty placeholder, formatted like its content would be. +fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { + // Layout/master prompt text when the layout supplies a custom one. + let (lp, _) = resolve::parents(rctx, s); + let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty()); + if let Some(t) = custom { + return t.clone(); + } + if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) { + return TextBody::default(); + } + let txt = kind.prompt(); + if txt.is_empty() || kind.is_footer_kind() { + return TextBody::default(); + } + let mut t = TextBody::from_text(txt); + if matches!(kind, PhType::Picture | PhType::Chart | PhType::Table | PhType::Media | PhType::Diagram | PhType::ClipArt) { + t.body.anchor = Some(slidecraft_model::text::Anchor::Bottom); + if let Some(p) = t.paragraphs.first_mut() { + p.props.align = Some(slidecraft_model::text::Align::Center); + p.props.bullet = Some(slidecraft_model::text::Bullet::None); + p.props.margin_left = Some(0.0); + p.props.indent = Some(0.0); + for r in &mut p.runs { + r.props.size = Some(14.0); + } + } + } + t +} + +thread_local! { + static GLYPH_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(HashMap::new()); +} + +fn glyph(face: &slidecraft_fonts::FontFace, gid: u32) -> Arc { + let key = (face.id(), gid); + if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { + return p; + } + let p = FontDb::global().outline(face, gid); + GLYPH_CACHE.with(|c| { + let mut c = c.borrow_mut(); + if c.len() > 200_000 { + c.clear(); + } + c.insert(key, p.clone()); + }); + p +} + +/// Draw a text body into shape space `m` within the text rect. +pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, tr: Rect, m: Affine, fields: &dyn Fields, prompt: Option) { + let l = slidecraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); + draw_layout(ctx, &l, tr, m); +} + +/// Draw an already laid-out text block. +pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr: Rect, m: Affine) { + let m = if l.rotation != 0.0 { + let c = tr.center().to_vec2(); + m * Affine::translate(c) * Affine::rotate(l.rotation.to_radians()) * Affine::translate(-c) + } else { + m + }; + for d in l.decos.iter().filter(|d| d.behind) { + ctx.set_transform(m); + ctx.set_paint(color(d.color, 1.0)); + ctx.fill_rect(&d.rect); + } + for run in &l.runs { + if (run.alpha <= 0.0 || run.color.a == 0) + && run.outline.is_none() { + continue; + } + let k = run.size / run.face.upem.max(1.0); + ctx.set_paint(color(run.color, run.alpha)); + for (gid, x, y) in &run.glyphs { + let path = glyph(&run.face, *gid); + if path.elements().is_empty() { + continue; + } + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let gm = m * Affine::translate((*x, *y)) * skew * Affine::scale(k); + ctx.set_transform(gm); + if run.alpha > 0.0 && run.color.a > 0 { + ctx.fill_path(&path); + if run.fake_bold { + ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + if let Some((oc, ow)) = run.outline { + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + ctx.set_paint(color(run.color, run.alpha)); + } + } + } + for d in l.decos.iter().filter(|d| !d.behind) { + ctx.set_transform(m); + ctx.set_paint(color(d.color, 1.0)); + ctx.fill_rect(&d.rect); + } +} + +/// Render one shape alone onto a transparent canvas (drag previews, copy as picture). +pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { + let rctx = Ctx::for_slide(pres, slide)?; + let s = slide.shape(id)?; + let x = resolve::xfrm(&rctx, s); + let b = x.bounds().inflate(8.0, 8.0); + let w = (b.width() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; + let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields { num: 1, date: String::new(), footer: String::new() } }; + let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + r.shape(&mut ctx, &f, &rctx, s, view, 0); + Some(r.finish(ctx, w, h)) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs new file mode 100644 index 0000000..1009aca --- /dev/null +++ b/crates/render/src/paint.rs @@ -0,0 +1,274 @@ +//! Fills: solid, gradient, picture and pattern paints. + +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use slidecraft_color::Rgba; +use slidecraft_geom::preset::FillMode; +use slidecraft_model::resolve::Ctx; +use slidecraft_model::style::{Fill, GradientShape, PictureFill, PictureMode}; +use slidecraft_model::{PhType, Presentation}; +use vello_cpu::{RenderContext, peniko}; + +use crate::{color, images, shade}; + +#[allow(clippy::too_many_arguments)] +pub fn fill_path( + ctx: &mut RenderContext, + rctx: &Ctx, + fill: &Fill, + ph: Option, + path: &BezPath, + bounds: Rect, + m: Affine, + mode: FillMode, + alpha: f64, + pres: &Presentation, +) { + ctx.set_transform(m); + match fill { + Fill::None | Fill::Group | Fill::Background => {} + Fill::Solid { color: c } => { + let col = shade(rctx.color(c, ph), mode); + ctx.set_paint(color(col, alpha)); + ctx.fill_path(path); + } + Fill::Gradient(g) => { + if g.stops.is_empty() { + return; + } + let mut stops: Vec<(f32, peniko::Color)> = + g.stops.iter().map(|s| (s.pos.clamp(0.0, 1.0) as f32, color(shade(rctx.color(&s.color, ph), mode), alpha))).collect(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + let stops: Vec = stops.into_iter().map(|(o, c)| peniko::ColorStop { offset: o, color: c.into() }).collect(); + let grad = match &g.shape { + GradientShape::Linear { angle, .. } => { + // Angle measured clockwise from left→right; span the bounds along that direction. + let a = angle.to_radians(); + let (dx, dy) = (a.cos(), a.sin()); + let c = bounds.center(); + let half = (bounds.width() * dx.abs() + bounds.height() * dy.abs()) / 2.0; + peniko::Gradient::new_linear(Point::new(c.x - dx * half, c.y - dy * half), Point::new(c.x + dx * half, c.y + dy * half)) + .with_stops(stops.as_slice()) + } + GradientShape::Path { focus, .. } => { + let fx = bounds.x0 + bounds.width() * (focus[0] + (1.0 - focus[2])) / 2.0; + let fy = bounds.y0 + bounds.height() * (focus[1] + (1.0 - focus[3])) / 2.0; + let r = ((bounds.width() / 2.0).powi(2) + (bounds.height() / 2.0).powi(2)).sqrt(); + peniko::Gradient::new_radial(Point::new(fx, fy), r as f32).with_stops(stops.as_slice()) + } + }; + ctx.set_paint(grad); + ctx.fill_path(path); + } + Fill::Picture(pf) => { + ctx.push_clip_layer(path); + picture(ctx, pres, pf, bounds, m); + ctx.pop_layer(); + } + Fill::Pattern(p) => { + let fg = rctx.color(&p.fg, ph); + let bg = rctx.color(&p.bg, ph); + ctx.set_paint(color(bg, alpha)); + ctx.fill_path(path); + ctx.push_clip_layer(path); + ctx.set_paint(color(fg, alpha)); + pattern(ctx, &p.preset, bounds, m); + ctx.pop_layer(); + } + } +} + +/// Draw a pattern's foreground marks over `bounds` (8 pt cells). +fn pattern(ctx: &mut RenderContext, preset: &str, b: Rect, m: Affine) { + let k = crate::scale_of(m); + let cell = 6.0 / if k.is_finite() { k.clamp(0.25, 4.0) } else { 1.0 }; + let cell = cell.max(2.0); + let mut p = BezPath::new(); + let (nx, ny) = (((b.width() / cell).ceil() as i64).clamp(0, 2000), ((b.height() / cell).ceil() as i64).clamp(0, 2000)); + let lw = cell * 0.18; + let dense = preset.starts_with("dk") || preset.contains("50") || preset.contains("75") || preset.contains("90"); + for j in 0..=ny { + for i in 0..=nx { + let x = b.x0 + i as f64 * cell; + let y = b.y0 + j as f64 * cell; + let r = match preset { + p if p.contains("Horz") || p == "horz" || p.contains("horzBrick") => Rect::new(x, y, x + cell, y + lw), + p if p.contains("Vert") || p == "vert" => Rect::new(x, y, x + lw, y + cell), + p if p.contains("cross") || p.contains("Grid") || p.contains("grid") => { + p_add(&mut p_rect(x, y, cell, lw), &mut BezPath::new()); + Rect::new(x, y, x + cell, y + lw).union(Rect::new(x, y, x + lw, y + cell)) + } + _ => { + let d = if dense { cell * 0.35 } else { cell * 0.2 }; + Rect::new(x, y, x + d, y + d) + } + }; + p.extend(r.to_path(0.1).iter()); + if preset.contains("cross") || preset.contains("Grid") || preset.contains("grid") { + p.extend(Rect::new(x, y, x + lw, y + cell).to_path(0.1).iter()); + } + } + } + ctx.fill_path(&p); +} + +fn p_rect(x: f64, y: f64, w: f64, h: f64) -> BezPath { + Rect::new(x, y, x + w, y + h).to_path(0.1) +} +fn p_add(_a: &mut BezPath, _b: &mut BezPath) {} + +/// Draw a picture fill into `bounds` (shape-local) honouring crop and stretch/tile modes. +pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, bounds: Rect, m: Affine) { + let Some(item) = pres.media(pf.media) else { + missing(ctx, bounds); + return; + }; + let px_scale = crate::scale_of(m); + let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0); + let Some(pm) = images::decode_for(&item.data, want, &pf.adjust) else { + missing(ctx, bounds); + return; + }; + let (iw, ih) = (pm.width() as f64, pm.height() as f64); + if iw < 1.0 || ih < 1.0 { + return; + } + let dest = match &pf.mode { + PictureMode::Stretch { fill_rect } => { + let [l, t, r, b] = *fill_rect; + let inner = Rect::new( + bounds.x0 + l * bounds.width(), + bounds.y0 + t * bounds.height(), + bounds.x1 - r * bounds.width(), + bounds.y1 - b * bounds.height(), + ); + crate::crop_dest(inner, pf.crop) + } + PictureMode::Tile { .. } => bounds, + }; + let alpha = pf.alpha.unwrap_or(1.0).clamp(0.0, 1.0); + let image = vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm.clone()), + sampler: peniko::ImageSampler { quality: peniko::ImageQuality::High, ..Default::default() }, + }; + if let PictureMode::Tile { sx, sy, tx, ty, .. } = &pf.mode { + let (sx, sy) = (if *sx > 0.0 { *sx } else { 1.0 }, if *sy > 0.0 { *sy } else { 1.0 }); + let tile_w = iw * 0.75 * sx; + let tile_h = ih * 0.75 * sy; + let nx = ((bounds.width() / tile_w.max(1.0)).ceil() as i64).clamp(0, 200); + let ny = ((bounds.height() / tile_h.max(1.0)).ceil() as i64).clamp(0, 200); + for j in -1..=ny { + for i in -1..=nx { + let r = Rect::new(bounds.x0 + tx + i as f64 * tile_w, bounds.y0 + ty + j as f64 * tile_h, 0.0, 0.0); + let r = Rect::new(r.x0, r.y0, r.x0 + tile_w, r.y0 + tile_h); + draw_image(ctx, &image, iw, ih, r, m, alpha); + } + } + return; + } + draw_image(ctx, &image, iw, ih, dest, m, alpha); +} + +fn draw_image(ctx: &mut RenderContext, image: &vello_cpu::Image, iw: f64, ih: f64, dest: Rect, m: Affine, alpha: f64) { + let t = m * Affine::translate(dest.origin().to_vec2()) * Affine::scale_non_uniform(dest.width() / iw, dest.height() / ih); + ctx.set_transform(t); + if alpha < 0.999 { + ctx.push_layer(None, None, Some(alpha as f32), None, None); + } + ctx.set_paint(image.clone()); + ctx.fill_rect(&Rect::new(0.0, 0.0, iw, ih)); + if alpha < 0.999 { + ctx.pop_layer(); + } +} + +fn missing(ctx: &mut RenderContext, b: Rect) { + ctx.set_paint(peniko::Color::from_rgba8(230, 230, 230, 255)); + ctx.fill_rect(&b); + ctx.set_paint(peniko::Color::from_rgba8(160, 160, 160, 255)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + let mut p = BezPath::new(); + p.move_to((b.x0, b.y0)); + p.line_to((b.x1, b.y1)); + p.move_to((b.x1, b.y0)); + p.line_to((b.x0, b.y1)); + ctx.stroke_path(&p); +} + +/// The speaker / film glyph on audio and video objects without a poster. +pub fn media_glyph(ctx: &mut RenderContext, video: bool, b: Rect) { + let s = b.width().min(b.height()); + let c = b.center(); + if video { + let r = s * 0.18; + ctx.set_paint(peniko::Color::from_rgba8(255, 255, 255, 220)); + ctx.fill_path(&kurbo::Circle::new(c, r * 1.4).to_path(0.1)); + let mut tri = BezPath::new(); + tri.move_to((c.x - r * 0.45, c.y - r * 0.7)); + tri.line_to((c.x + r * 0.75, c.y)); + tri.line_to((c.x - r * 0.45, c.y + r * 0.7)); + tri.close_path(); + ctx.set_paint(peniko::Color::from_rgba8(30, 30, 30, 255)); + ctx.fill_path(&tri); + return; + } + let k = s / 48.0; + let mut p = BezPath::new(); + p.move_to((c.x - 14.0 * k, c.y - 6.0 * k)); + p.line_to((c.x - 6.0 * k, c.y - 6.0 * k)); + p.line_to((c.x + 4.0 * k, c.y - 15.0 * k)); + p.line_to((c.x + 4.0 * k, c.y + 15.0 * k)); + p.line_to((c.x - 6.0 * k, c.y + 6.0 * k)); + p.line_to((c.x - 14.0 * k, c.y + 6.0 * k)); + p.close_path(); + ctx.set_paint(peniko::Color::from_rgba8(0x2E, 0x6F, 0xD8, 255)); + ctx.fill_path(&p); + let mut w = BezPath::new(); + for r in [9.0, 15.0] { + let arc = kurbo::Arc::new((c.x + 4.0 * k, c.y), (r * k, r * k), -0.8, 1.6, 0.0); + w.extend(arc.path_elements(0.1)); + } + ctx.set_stroke(kurbo::Stroke::new(2.5 * k).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&w); +} + +/// A content-type icon drawn in empty picture/chart/table placeholders. +pub fn placeholder_icon(ctx: &mut RenderContext, kind: PhType, b: Rect) { + let s = (b.width().min(b.height()) * 0.18).clamp(10.0, 48.0); + let c = Point::new(b.center().x, b.center().y - s * 0.3); + let r = Rect::new(c.x - s / 2.0, c.y - s / 2.0, c.x + s / 2.0, c.y + s / 2.0); + ctx.set_paint(peniko::Color::from_rgba8(0x8C, 0x8C, 0x8C, 255)); + ctx.set_stroke(kurbo::Stroke::new(s * 0.06).with_join(kurbo::Join::Round)); + match kind { + PhType::Chart => { + for (i, h) in [0.45, 0.8, 0.6].iter().enumerate() { + let x = r.x0 + s * (0.1 + 0.3 * i as f64); + ctx.fill_rect(&Rect::new(x, r.y1 - s * h, x + s * 0.2, r.y1)); + } + } + PhType::Table => { + ctx.stroke_path(&r.to_path(0.1)); + let mut p = BezPath::new(); + for k in 1..3 { + let x = r.x0 + s * k as f64 / 3.0; + let y = r.y0 + s * k as f64 / 3.0; + p.move_to((x, r.y0)); + p.line_to((x, r.y1)); + p.move_to((r.x0, y)); + p.line_to((r.x1, y)); + } + ctx.stroke_path(&p); + } + _ => { + ctx.stroke_path(&r.to_path(0.1)); + let mut m = BezPath::new(); + m.move_to((r.x0, r.y1)); + m.line_to((r.x0 + s * 0.35, r.y0 + s * 0.45)); + m.line_to((r.x0 + s * 0.6, r.y0 + s * 0.7)); + m.line_to((r.x0 + s * 0.75, r.y0 + s * 0.55)); + m.line_to((r.x1, r.y1)); + m.close_path(); + ctx.fill_path(&m); + ctx.fill_path(&kurbo::Circle::new((r.x0 + s * 0.72, r.y0 + s * 0.25), s * 0.1).to_path(0.1)); + } + } +} diff --git a/crates/render/src/table.rs b/crates/render/src/table.rs new file mode 100644 index 0000000..0bf79c9 --- /dev/null +++ b/crates/render/src/table.rs @@ -0,0 +1,214 @@ +//! Tables: cell fills from the table style (or overrides), borders, and cell text. + +use kurbo::{Affine, BezPath, Rect, Shape as _}; +use slidecraft_color::{ColorTransform, Rgba, SchemeSlot}; +use slidecraft_model::resolve::Ctx; +use slidecraft_model::style::{ColorRef, Fill}; +use slidecraft_model::table::Table; +use slidecraft_model::text::Anchor; +use slidecraft_model::{Presentation, Shape}; +use vello_cpu::{RenderContext, peniko}; + +use crate::color; + +/// Style colours for a cell: (fill, text colour, bold). +pub struct CellLook { + pub fill: Option, + pub text: Option, + pub bold: bool, + pub border: Option<(Rgba, f64)>, +} + +fn accent(ctx: &Ctx, style: &str) -> Rgba { + let slot = match style.rsplit('-').next().unwrap_or("accent1") { + "accent2" => SchemeSlot::Accent2, + "accent3" => SchemeSlot::Accent3, + "accent4" => SchemeSlot::Accent4, + "accent5" => SchemeSlot::Accent5, + "accent6" => SchemeSlot::Accent6, + "tx1" => SchemeSlot::Tx1, + _ => SchemeSlot::Accent1, + }; + ctx.color(&ColorRef::scheme(slot), None) +} + +pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook { + let a = accent(ctx, &t.style); + let kind = t.style.split('-').next().unwrap_or("medium2"); + let header = t.first_row && r == 0; + let total = t.last_row && r + 1 == t.rows.len(); + let first_col = t.first_col && c == 0; + let last_col = t.last_col && c + 1 == t.cols.len(); + let body_r = if t.first_row { r.wrapping_sub(1) } else { r }; + let band = t.band_row && !header && !total && body_r % 2 == 0; + let band_c = t.band_col && c.is_multiple_of(2); + let tint = |k: i32| slidecraft_color::apply(a, &[ColorTransform::Tint(k)]); + let white = Rgba::WHITE; + let dark_text = ctx.color(&ColorRef::scheme(SchemeSlot::Tx1), None); + match kind { + "none" => CellLook { fill: None, text: None, bold: false, border: None }, + "grid" => CellLook { fill: None, text: None, bold: false, border: Some((dark_text, 0.75)) }, + "light1" => CellLook { + fill: if band || band_c { Some(tint(20000)) } else { None }, + text: None, + bold: header || total || first_col || last_col, + border: if header { Some((a, 1.0)) } else { None }, + }, + "light2" => CellLook { + fill: if header { Some(a) } else { None }, + text: if header { Some(white) } else { None }, + bold: header || total || first_col, + border: Some((a, 1.0)), + }, + "dark1" => CellLook { + fill: Some(if header { + Rgba::BLACK + } else if band || band_c { + slidecraft_color::apply(a, &[ColorTransform::Shade(60000)]) + } else { + slidecraft_color::apply(a, &[ColorTransform::Shade(75000)]) + }), + text: Some(white), + bold: header || total || first_col, + border: Some((white, 1.0)), + }, + "medium4" => CellLook { + fill: Some(if header || band || band_c { + tint(40000) + } else { + tint(20000) + }), + text: None, + bold: header || total || first_col || last_col, + border: Some((a, 0.75)), + }, + _ => CellLook { + // medium2: strong header, banded light tints, white grid. + fill: Some(if header || total || first_col || last_col { + a + } else if band || band_c { + tint(40000) + } else { + tint(20000) + }), + text: if header || total || first_col || last_col { Some(white) } else { None }, + bold: header || total || first_col || last_col, + border: Some((white, 1.0)), + }, + } +} + +/// Cell rectangles in table-local space, honouring merges: (row, col, rect) for visible cells. +pub fn cell_rects(t: &Table) -> Vec<(usize, usize, Rect)> { + let mut out = vec![]; + let xs: Vec = std::iter::once(0.0) + .chain(t.cols.iter().scan(0.0, |a, w| { + *a += w.max(0.0); + Some(*a) + })) + .collect(); + let ys: Vec = std::iter::once(0.0) + .chain(t.rows.iter().scan(0.0, |a, r| { + *a += r.height.max(0.0); + Some(*a) + })) + .collect(); + for (ri, row) in t.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate().take(t.cols.len()) { + if cell.is_covered() { + continue; + } + let c1 = (ci + cell.grid_span.max(1) as usize).min(t.cols.len()); + let r1 = (ri + cell.row_span.max(1) as usize).min(t.rows.len()); + let (Some(x0), Some(x1), Some(y0), Some(y1)) = (xs.get(ci), xs.get(c1), ys.get(ri), ys.get(r1)) else { continue }; + out.push((ri, ci, Rect::new(*x0, *y0, *x1, *y1))); + } + } + out +} + +pub fn draw(ctx: &mut RenderContext, pres: &Presentation, rctx: &Ctx, t: &Table, m: Affine, _w: f64, _h: f64, fields: &dyn slidecraft_text::Fields) { + let _ = pres; + let rects = cell_rects(t); + for (r, c, rect) in &rects { + let Some(cell) = t.cell(*r, *c) else { continue }; + let lk = look(rctx, t, *r, *c); + ctx.set_transform(m); + match &cell.fill { + Some(f) => { + crate::paint::fill_path(ctx, rctx, f, None, &rect.to_path(0.1), *rect, m, slidecraft_geom::preset::FillMode::Norm, 1.0, rctx.pres) + } + None => { + if let Some(fc) = lk.fill { + ctx.set_paint(color(fc, 1.0)); + ctx.fill_rect(rect); + } + } + } + } + // Borders: explicit cell borders, else the style's grid. + for (r, c, rect) in &rects { + let Some(cell) = t.cell(*r, *c) else { continue }; + let lk = look(rctx, t, *r, *c); + let edges = [ + (rect.x0, rect.y0, rect.x0, rect.y1), + (rect.x1, rect.y0, rect.x1, rect.y1), + (rect.x0, rect.y0, rect.x1, rect.y0), + (rect.x0, rect.y1, rect.x1, rect.y1), + ]; + for (i, e) in edges.iter().enumerate() { + let line = cell.borders.get(i).and_then(|b| b.as_ref()); + let (col, wd) = match line { + Some(l) => match &l.fill { + Some(Fill::Solid { color: cc }) => (rctx.color(cc, None), l.width.unwrap_or(1.0)), + Some(Fill::None) => continue, + _ => continue, + }, + None => match lk.border { + Some(b) => b, + None => continue, + }, + }; + let mut p = BezPath::new(); + p.move_to((e.0, e.1)); + p.line_to((e.2, e.3)); + ctx.set_transform(m); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(wd.max(0.25))); + ctx.stroke_path(&p); + } + } + // Text. + for (r, c, rect) in &rects { + let Some(cell) = t.cell(*r, *c) else { continue }; + if cell.text.is_empty() { + continue; + } + let lk = look(rctx, t, *r, *c); + let mut body = cell.text.clone(); + let [ml, mr, mt, mb] = cell.margins.unwrap_or([7.2, 7.2, 3.6, 3.6]); + body.body.inset_l = Some(ml); + body.body.inset_r = Some(mr); + body.body.inset_t = Some(mt); + body.body.inset_b = Some(mb); + body.body.anchor = Some(cell.anchor.unwrap_or(Anchor::Top)); + if cell.vertical.is_some() { + body.body.vert = cell.vertical; + } + for p in &mut body.paragraphs { + for run in &mut p.runs { + if run.props.fill.is_none() + && let Some(tc) = lk.text + { + run.props.fill = Some(Fill::solid(ColorRef::rgb(tc))); + } + if lk.bold && run.props.bold.is_none() { + run.props.bold = Some(true); + } + } + } + let tmp = Shape { text: Some(body.clone()), ..Default::default() }; + crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None); + let _ = peniko::Fill::NonZero; + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs new file mode 100644 index 0000000..2ec5d06 --- /dev/null +++ b/crates/render/src/tests.rs @@ -0,0 +1,159 @@ +use super::*; +use slidecraft_model::chart::ChartType; +use slidecraft_model::style::ColorRef; +use slidecraft_model::text::TextBody; +use slidecraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; + +fn deck_with(shapes: Vec) -> Presentation { + let mut p = Presentation::default(); + let s = std::sync::Arc::make_mut(&mut p.slides[0]); + s.shapes = shapes; + p +} + +#[test] +fn blank_slide_renders_white_background() { + let p = Presentation::default(); + let img = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() }); + assert_eq!((img.width, img.height), (240, 135)); + assert_eq!(img.pixel(5, 5), [255, 255, 255, 255]); +} + +#[test] +fn shape_fill_uses_theme_accent() { + let sh = Shape { + id: ShapeId(500), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(slidecraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let accent = p.masters[0].theme.colors.get(slidecraft_color::SchemeSlot::Accent1); + let px = img.pixel(200, 150); + assert!((px[0] as i32 - accent.r as i32).abs() <= 2 && (px[2] as i32 - accent.b as i32).abs() <= 2, "{px:?} vs {accent:?}"); + assert_eq!(img.pixel(50, 50), [255, 255, 255, 255]); +} + +#[test] +fn text_draws_ink() { + let mut sh = Shape { id: ShapeId(501), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), text_box: true, ..Default::default() }; + let mut t = TextBody::from_text("HELLO WORLD"); + t.paragraphs[0].runs[0].props.size = Some(120.0); + sh.text = Some(t); + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() }); + let dark = img.pixels.as_chunks::<4>().0.iter().filter(|px| px[0] < 128).count(); + assert!(dark > 500, "dark pixels {dark}"); +} + +#[test] +fn every_preset_renders_without_panic() { + let shapes: Vec = slidecraft_geom::preset::CATALOG + .iter() + .enumerate() + .map(|(i, pr)| Shape { + id: ShapeId(1000 + i as u32), + xfrm: Some(Xfrm::new((i % 16) as f64 * 60.0, (i / 16) as f64 * 55.0, 50.0, 45.0)), + geom: Geom::preset(pr.name), + style: Some(ShapeStyle::accent(slidecraft_color::SchemeSlot::Accent2)), + ..Default::default() + }) + .collect(); + let p = deck_with(shapes); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, edit: true, ..Default::default() }); + assert_eq!(img.width, 960); +} + +#[test] +fn effects_tables_charts_render() { + let mut sh = Shape { + id: ShapeId(600), + xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(slidecraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + sh.effects = Some(slidecraft_model::Effects { + outer_shadow: Some(slidecraft_model::style::Shadow { + color: ColorRef::rgb(slidecraft_color::Rgba::BLACK), + blur: 8.0, + dist: 6.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }), + glow: Some(slidecraft_model::style::Glow { color: ColorRef::rgb(slidecraft_color::Rgba::rgb(255, 200, 0)), radius: 6.0 }), + soft_edge: Some(4.0), + ..Default::default() + }); + let mut t = Table::new(3, 3, 300.0, 30.0); + t.cell_mut(0, 0).unwrap().text = TextBody::from_text("Head"); + let tbl = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(300.0, 50.0, 300.0, 90.0)), kind: ShapeKind::Table(t), ..Default::default() }; + let mut charts = vec![]; + for (i, k) in [ + ChartType::Column, + ChartType::Bar, + ChartType::Line, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Area, + ChartType::Scatter, + ChartType::StackedColumn, + ] + .iter() + .enumerate() + { + charts.push(Shape { + id: ShapeId(700 + i as u32), + xfrm: Some(Xfrm::new(i as f64 * 110.0, 300.0, 100.0, 100.0)), + kind: ShapeKind::Chart(Box::new(Chart::sample(*k))), + ..Default::default() + }); + } + let mut all = vec![sh, tbl]; + all.extend(charts); + let p = deck_with(all); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + // Shadow darkens below-right of the shape. + let px = img.pixel(258, 156); + assert!(px[0] < 250, "{px:?}"); +} + +#[test] +fn placeholder_prompts_only_in_edit_view() { + let p = Presentation::default(); + let edit = render_slide(&p, 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() }); + let show = render_slide(&p, 0, &RenderOpts { scale: 0.5, ..Default::default() }); + let ink = |i: &Image| i.pixels.as_chunks::<4>().0.iter().filter(|px| px[0] < 200).count(); + assert!(ink(&edit) > 100); + assert_eq!(ink(&show), 0); +} + +#[test] +fn png_encodes() { + let p = Presentation::default(); + let img = render_slide(&p, 0, &RenderOpts { scale: 0.1, ..Default::default() }); + let png = img.to_png(); + assert!(png.starts_with(&[0x89, b'P', b'N', b'G'])); + assert!(!img.to_jpeg(80).is_empty()); +} + +#[test] +fn crop_dest_math() { + let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [0.5, 0.0, 0.0, 0.0]); + assert!((r.x0 + 100.0).abs() < 1e-9 && (r.width() - 200.0).abs() < 1e-9); + let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [f64::NAN, 2.0, -50.0, 0.0]); + assert!(r.x0.is_finite() && r.y1.is_finite()); +} + +#[test] +fn custom_path_parse() { + let p = parse_path("M 0 0 L 10 0 C 1 2 3 4 5 6 Q 1 1 2 2 Z garbage L x y"); + assert!(p.elements().len() >= 5); + assert!(parse_path("").elements().is_empty()); +} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml new file mode 100644 index 0000000..f8adf47 --- /dev/null +++ b/crates/text/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "slidecraft-text" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft text layout: runs to shaped glyph lines with bullets, indents, spacing, alignment, anchoring, autofit and caret hit testing" + +[lints] +workspace = true + +[dependencies] +slidecraft-model = { workspace = true } +slidecraft-geom = { workspace = true } +slidecraft-color = { workspace = true } +slidecraft-fonts = { workspace = true } +unicode-linebreak = { workspace = true } +unicode-segmentation = { workspace = true } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs new file mode 100644 index 0000000..a9d22b9 --- /dev/null +++ b/crates/text/src/lib.rs @@ -0,0 +1,1092 @@ +//! Text layout for slide text bodies. +//! +//! [`layout`] resolves every run's formatting through the placeholder/style chain, shapes it with +//! font fallback, breaks lines (UAX #14 opportunities, greedy fill), places bullets and numbers, +//! applies indents, spacing and alignment, anchors the block in the shape's text box, and shrinks +//! text on overflow when the body asks for it. The result is positioned glyph runs in shape-local +//! coordinates plus per-line caret positions for editing. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use slidecraft_color::Rgba; +use slidecraft_fonts::{FontDb, FontFace}; +use slidecraft_geom::{Point, Rect}; +use slidecraft_model::resolve::{self, Ctx}; +use slidecraft_model::text::{Align, Anchor, AutoFit, BodyProps, Bullet, Caps, ParaProps, RunKind, RunProps, Spacing, Strike, TextBody, TextDir}; +use slidecraft_model::{Fill, Shape}; + +/// A run of glyphs in one face, size and colour. +#[derive(Clone)] +pub struct GlyphRun { + pub face: Arc, + /// Font size in points (after autofit scaling). + pub size: f64, + pub color: Rgba, + /// Glyph id and pen position of its origin (baseline), in shape-local points. + pub glyphs: Vec<(u32, f64, f64)>, + /// Draw a heavier stroke because the family has no bold face. + pub fake_bold: bool, + /// Slant because the family has no italic face. + pub fake_italic: bool, + pub outline: Option<(Rgba, f64)>, + /// Paragraph and character range this run covers (for hyperlinks, selection colours). + pub para: usize, + pub chars: (usize, usize), + pub link: bool, + /// Alpha of the fill (0–1). + pub alpha: f64, + pub gradient: Option, +} + +impl std::fmt::Debug for GlyphRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GlyphRun").field("family", &self.face.family).field("size", &self.size).field("glyphs", &self.glyphs.len()).finish() + } +} + +/// An underline, strikethrough or highlight box. +#[derive(Clone, Debug)] +pub struct Deco { + pub rect: Rect, + pub color: Rgba, + /// Highlights draw behind text. + pub behind: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct LineInfo { + pub para: usize, + /// Character range [start, end) in the paragraph (end excludes the line's break/newline). + pub start: usize, + pub end: usize, + pub top: f64, + pub baseline: f64, + pub bottom: f64, + /// Caret x for each character boundary start..=end (len = end - start + 1). + pub caret_x: Vec, + /// Column index (multi-column bodies). + pub column: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct TextLayout { + pub runs: Vec, + pub decos: Vec, + pub lines: Vec, + /// Inner text box (insets applied), shape-local. + pub inner: Rect, + /// Height of the laid-out text (before anchoring). + pub content_height: f64, + /// Widest line. + pub content_width: f64, + /// Autofit font scale used (1 = none). + pub font_scale: f64, + pub line_reduction: f64, + pub overflow: bool, + /// Text rotation about the shape centre, degrees (vertical text = 90/270). + pub rotation: f64, + /// Per paragraph: (first line index, line count). + pub para_lines: Vec<(usize, usize)>, +} + +/// Values for fields (`slidenum`, `datetime…`, `footer`). +pub trait Fields { + fn field(&self, kind: &str) -> Option; +} + +pub struct NoFields; +impl Fields for NoFields { + fn field(&self, kind: &str) -> Option { + if kind == "slidenum" { Some("1".into()) } else { None } + } +} + +pub struct Opts<'a> { + /// The text box: the preset's text rectangle in shape-local space. + pub rect: Rect, + pub fields: &'a dyn Fields, + /// Draw this text instead of the body (placeholder prompts), in this colour. + pub prompt_color: Option, + /// Ignore autofit shrinking (used to measure natural height). + pub no_shrink: bool, +} + +struct Style { + face: Arc, + size: f64, + color: Rgba, + alpha: f64, + fake_bold: bool, + fake_italic: bool, + baseline: f64, + spacing: f64, + caps: Caps, + underline: Option, + strike: Option, + highlight: Option, + outline: Option<(Rgba, f64)>, + link: bool, + gradient: Option, +} + +/// One shaped character cell of a paragraph. +#[derive(Clone)] +struct Cell { + ch: char, + style: usize, + /// Glyphs anchored at this char (gid, x offset from char start, y offset). + glyphs: Vec<(u32, f64, f64)>, + adv: f64, + face: usize, +} + +struct ParaShaped { + cells: Vec, + styles: Vec