diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..2a4cd2cb9 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,3 @@ +# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI +# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). +nextest-version = "0.9.150" diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538b..ea61d8430 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,27 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d2f76f7bb..1e5b38835 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -29,3 +29,5 @@ jobs: with: app-name: deckcraft app-dir: DeckCraft + install-relative-path: Programs/ArtCraft/DeckCraft + install-scope: perUser diff --git a/AGENTS.md b/AGENTS.md index 8a7ce7cf5..c998e8715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,3 +192,8 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev GitHub profiles). Never hand-edit `contributors.json`. - Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/CLAUDE.md b/CLAUDE.md index 67853ff06..669965b59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,10 @@ Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and win over anything here. +New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit +`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes +before modules or other crate items; crate-level `//!` documentation may precede them. + ## Start every session 1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`. 2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task. @@ -15,3 +19,8 @@ win over anything here. - Commit after each landed arc (`M.: what`) and push to `origin main`. - Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current. - Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 0986dafc0..a6ef452e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -222,7 +222,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -642,6 +642,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -811,10 +820,38 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + [[package]] name = "craft-media" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "thiserror 2.0.21", ] @@ -822,22 +859,43 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "krilla", "thiserror 2.0.21", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -926,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "deckcraft" -version = "0.11.1" +version = "0.12.0" dependencies = [ "arboard", "chrono", @@ -948,7 +1006,7 @@ dependencies = [ [[package]] name = "deckcraft-anim" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-geom", "deckcraft-model", @@ -957,7 +1015,7 @@ dependencies = [ [[package]] name = "deckcraft-av1" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -966,14 +1024,14 @@ dependencies = [ [[package]] name = "deckcraft-bitstream" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-cli" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-mcp", @@ -983,14 +1041,14 @@ dependencies = [ [[package]] name = "deckcraft-color" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde", ] [[package]] name = "deckcraft-engine" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "deckcraft-anim", @@ -1016,7 +1074,7 @@ dependencies = [ [[package]] name = "deckcraft-fonts" -version = "0.11.1" +version = "0.12.0" dependencies = [ "epaint_default_fonts", "harfrust 0.13.3", @@ -1028,7 +1086,7 @@ dependencies = [ [[package]] name = "deckcraft-format" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-model", "serde", @@ -1039,7 +1097,7 @@ dependencies = [ [[package]] name = "deckcraft-geom" -version = "0.11.1" +version = "0.12.0" dependencies = [ "kurbo", "serde", @@ -1047,7 +1105,7 @@ dependencies = [ [[package]] name = "deckcraft-h264" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1056,7 +1114,7 @@ dependencies = [ [[package]] name = "deckcraft-hevc" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1065,21 +1123,21 @@ dependencies = [ [[package]] name = "deckcraft-isobmff" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-matroska" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-media", ] [[package]] name = "deckcraft-mcp" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-render", @@ -1089,7 +1147,7 @@ dependencies = [ [[package]] name = "deckcraft-media" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-av1", "deckcraft-h264", @@ -1107,7 +1165,7 @@ dependencies = [ [[package]] name = "deckcraft-metafile" -version = "0.11.1" +version = "0.12.0" dependencies = [ "image", "kurbo", @@ -1116,7 +1174,7 @@ dependencies = [ [[package]] name = "deckcraft-model" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-color", "deckcraft-geom", @@ -1128,15 +1186,15 @@ dependencies = [ [[package]] name = "deckcraft-ogg" -version = "0.11.1" +version = "0.12.0" [[package]] name = "deckcraft-opus" -version = "0.11.1" +version = "0.12.0" [[package]] name = "deckcraft-pdf" -version = "0.11.1" +version = "0.12.0" dependencies = [ "craft-pdf", "deckcraft-fonts", @@ -1154,7 +1212,7 @@ dependencies = [ [[package]] name = "deckcraft-pptx" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-color", "deckcraft-engine", @@ -1170,7 +1228,7 @@ dependencies = [ [[package]] name = "deckcraft-render" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-anim", "deckcraft-color", @@ -1189,7 +1247,7 @@ dependencies = [ [[package]] name = "deckcraft-text" -version = "0.11.1" +version = "0.12.0" dependencies = [ "chrono", "deckcraft-color", @@ -1203,8 +1261,9 @@ dependencies = [ [[package]] name = "deckcraft-ui-egui" -version = "0.11.1" +version = "0.12.0" dependencies = [ + "craft-ui", "craft-update", "deckcraft-anim", "deckcraft-color", @@ -1227,7 +1286,7 @@ dependencies = [ [[package]] name = "deckcraft-vp9" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-bitstream", "rayon", @@ -1236,7 +1295,7 @@ dependencies = [ [[package]] name = "deckcraft-web" -version = "0.11.1" +version = "0.12.0" dependencies = [ "deckcraft-engine", "deckcraft-ui-egui", @@ -1593,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1680,6 +1739,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2250,6 +2320,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2258,11 +2345,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2274,6 +2382,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2283,6 +2394,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2312,6 +2449,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2319,6 +2459,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2544,7 +2708,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2645,6 +2809,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -2847,7 +3014,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3320,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.45.0", ] [[package]] @@ -3562,12 +3729,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -3863,7 +4042,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3920,7 +4099,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4524,6 +4703,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4541,10 +4732,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4667,6 +4858,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4920,6 +5112,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -5461,7 +5659,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5567,6 +5765,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -5882,11 +6091,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -5959,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.1" +version = "0.12.0" dependencies = [ "serde_json", ] @@ -6147,8 +6365,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6157,6 +6378,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index fb54bd62d..3eada7087 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] [workspace.package] -version = "0.11.1" +version = "0.12.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -59,9 +59,11 @@ harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } -craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } i_overlay = "9" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } @@ -79,9 +81,11 @@ arboard = { version = "3.6", default-features = false } unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" -too_many_arguments = "allow" type_complexity = "allow" self_named_constructors = "allow" large_enum_variant = "allow" @@ -105,7 +109,7 @@ codegen-units = 16 debug = 0 [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index a3c03ce2a..c2c38e10f 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -73,7 +73,7 @@ fn main() -> ExitCode { Some("app") => report(app(rest)), Some("mcp") => report(mcp(rest)), Some("links") => { - use deckcraft_engine::links::*; + use deckcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}"); ExitCode::SUCCESS } @@ -208,7 +208,7 @@ fn bench(args: &[String]) -> Result<(), String> { ("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?), ("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?), ("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?), - ("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), + ("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?), ]; let render_total: f64 = per_slide.iter().map(|m| m.0).sum(); let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max); diff --git a/apps/deckcraft/src/control_server.rs b/apps/deckcraft/src/control_server.rs index 6088f343d..033ae4a4f 100644 --- a/apps/deckcraft/src/control_server.rs +++ b/apps/deckcraft/src/control_server.rs @@ -307,304 +307,4 @@ fn serve(stream: TcpStream, tx: &SyncSender, ctx: &egui::Context } #[cfg(test)] -mod tests { - use super::*; - - /// A failed accept is followed by a wait before the next one, so a listener that keeps - /// failing does not spin, and the connections around the failures still come through. - #[test] - fn a_failed_accept_waits_before_the_next() { - let failed = || Err(std::io::Error::other("no file descriptors left")); - let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; - let mut waits = 0; - let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); - assert_eq!(connections, [1, 2]); - assert_eq!(waits, 3, "one wait per failed accept"); - } - use std::net::SocketAddr; - use std::time::Instant; - - fn small() -> Limits { - Limits { - max_clients: 2, - max_line: 64, - queue: 1, - idle_timeout: Duration::from_millis(300), - write_timeout: Duration::from_millis(300), - reply_timeout: Duration::from_millis(300), - } - } - - fn server(limits: Limits) -> (SocketAddr, Receiver) { - let l = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = l.local_addr().unwrap(); - (addr, serve_listener(l, limits, egui::Context::default())) - } - - fn client(addr: SocketAddr) -> (BufReader, TcpStream) { - let s = TcpStream::connect(addr).unwrap(); - s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - (BufReader::new(s.try_clone().unwrap()), s) - } - - fn reply(r: &mut BufReader) -> Option { - let mut s = String::new(); - match r.read_line(&mut s) { - Ok(0) | Err(_) => None, - Ok(_) => serde_json::from_str(&s).ok(), - } - } - - /// Answer every request with its method name until the server goes away. - fn ui(rx: Receiver) { - std::thread::spawn(move || { - for req in rx { - let _ = req.reply.send(json!({"ok": true, "result": req.method})); - } - }); - } - - #[test] - fn request_round_trip() { - let (addr, rx) = server(small()); - ui(rx); - let (mut r, mut w) = client(addr); - writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap(); - let v = reply(&mut r).unwrap(); - assert_eq!(v["id"], 7); - assert_eq!(v["result"], "ui.inspect"); - } - - /// Every reply line until the server closes the connection. - fn replies_until_closed(r: &mut BufReader) -> Vec { - std::iter::from_fn(|| reply(r)).collect() - } - - /// A browser can POST to the port: its request line closes the connection before the JSON - /// line in its body is read, so nothing is dispatched. - #[test] - fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { - let (addr, rx) = server(Limits { max_line: 512, ..small() }); - let (mut r, mut w) = client(addr); - w.write_all( - b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\ - {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n", - ) - .unwrap(); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); - assert!(rx.try_recv().is_err(), "the body was dispatched"); - } - - #[test] - fn a_line_that_is_not_a_request_closes_the_connection() { - let (addr, rx) = server(small()); - ui(rx); - for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { - let (mut r, mut w) = client(addr); - w.write_all(junk).unwrap(); - w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert_eq!(replies[0]["ok"], false); - } - assert!(is_http_request_line("OPTIONS * HTTP/1.1")); - assert!(is_http_request_line("PRI * HTTP/2.0")); - assert!(!is_http_request_line("{\"method\":\"GET /\"}")); - } - - /// A peer that pipelines -- two lines in one write, as a script does -- still reads why its - /// line was rejected. Closing with its second line unread resets the connection and discards - /// the reply already on its way, so the peer saw nothing at all; repeated here because it - /// depended on the timing of the two. - #[test] - fn a_pipelined_peer_still_reads_why_its_line_was_rejected() { - // A slot comes back only when the thread serving a peer ends, which can be after the next - // peer connects, so every peer has a slot of its own. - const PEERS: usize = 12; - let (addr, rx) = server(Limits { max_clients: PEERS, ..small() }); - for _ in 0..PEERS { - let (mut r, mut w) = client(addr); - w.write_all(b"hello\n").unwrap(); - // A second line, written after the first as a script writes it: it races the close. - let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert_eq!(replies[0]["ok"], false); - assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}"); - } - // The line after the rejected one is never dispatched, pipelined or not. - assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched"); - } - - /// A peer refused because every connection is taken still reads why when it had already - /// written a line: closing with that line unread resets the connection and discards the - /// reply. Repeated because it depends on the timing of the two. - #[test] - fn a_pipelined_peer_refused_as_busy_still_reads_why() { - let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - // Take the only slot, and make sure it is being served. - let (mut hr, mut hw) = client(addr); - writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - assert!(reply(&mut hr).is_some()); - for _ in 0..50 { - let (mut r, mut w) = client(addr); - let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); - let replies = replies_until_closed(&mut r); - assert_eq!(replies.len(), 1, "{replies:?}"); - assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}"); - } - } - - /// Peers refused as busy that stay connected without sending anything do not hold up the - /// connections after them: each one's drain runs to its deadline on a thread of its own, and - /// past `MAX_REFUSING` drains at once a refused connection is closed without one. - #[test] - fn silent_refused_peers_do_not_delay_the_next_refusal() { - let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - let (mut hr, mut hw) = client(addr); - writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - assert!(reply(&mut hr).is_some()); - // Twenty drains of 250 ms each, one after another, would take five seconds. - let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect(); - let start = Instant::now(); - let (mut r, _w) = client(addr); - let refused = reply(&mut r).unwrap(); - assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}"); - assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed()); - drop(silent); - } - - /// A peer that keeps its connection open after its line was rejected gives its slot back at - /// the drain deadline (the write timeout), not at the idle timeout. - #[test] - fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() { - let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - let (mut r, mut w) = client(addr); - w.write_all(b"hello\n").unwrap(); - assert_eq!(reply(&mut r).unwrap()["ok"], false); - // `w` stays open, sending nothing. - let deadline = Instant::now() + Duration::from_secs(4); - loop { - let (mut dr, mut dw) = client(addr); - writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); - if reply(&mut dr).is_some_and(|v| v["ok"] == true) { - break; - } - assert!(Instant::now() < deadline, "slot never freed"); - std::thread::sleep(Duration::from_millis(50)); - } - drop(w); - } - - #[test] - fn oversized_line_closes_connection() { - let (addr, rx) = server(small()); - ui(rx); - let (mut r, mut w) = client(addr); - w.write_all(&[b'x'; 200]).unwrap(); - let v = reply(&mut r).unwrap(); - assert!(v["error"].as_str().unwrap().contains("longer than 64")); - assert!(reply(&mut r).is_none()); - } - - #[test] - fn idle_connection_is_closed() { - let (addr, rx) = server(small()); - ui(rx); - let (mut r, mut w) = client(addr); - w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence - let t = Instant::now(); - assert!(reply(&mut r).is_none()); - assert!(t.elapsed() < Duration::from_secs(4)); - } - - #[test] - fn connection_limit_refuses_then_frees_slot() { - // `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the - // third peer connected, and that peer then took its slot instead of reading `busy`. - let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() }); - ui(rx); - let a = client(addr); - let (mut br, mut bw) = client(addr); - // Make sure both are being served before the third arrives. - writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - assert!(reply(&mut br).is_some()); - let (mut cr, _cw) = client(addr); - let v = reply(&mut cr).unwrap(); - assert!(v["error"].as_str().unwrap().starts_with("busy")); - drop(a); - // The freed slot is reusable once the server notices the disconnect. - let deadline = Instant::now() + Duration::from_secs(4); - loop { - let (mut dr, mut dw) = client(addr); - writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); - if reply(&mut dr).is_some_and(|v| v["ok"] == true) { - break; - } - assert!(Instant::now() < deadline, "slot never freed"); - std::thread::sleep(Duration::from_millis(50)); - } - } - - #[test] - fn blocked_ui_times_out_and_full_queue_is_busy() { - let (addr, rx) = server(small()); - // No UI: the request that reaches the one-slot queue first fills it and times out; the - // other finds it full. Each connection has its own thread, so either request can be first. - let (mut ar, mut aw) = client(addr); - let (mut br, mut bw) = client(addr); - writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap(); - writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap(); - let a = reply(&mut ar).unwrap(); - let b = reply(&mut br).unwrap(); - assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2))); - let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()]; - errors.sort_unstable(); - assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}"); - drop(rx); - } - - /// A request that timed out while it was still queued must not run when the window resumes (#719). - #[test] - fn timed_out_request_does_not_run_when_the_window_resumes() { - let (addr, rx) = server(Limits { max_line: 512, ..small() }); - let (mut r, mut w) = client(addr); - writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap(); - let t = reply(&mut r).unwrap(); - assert_eq!(t["error"], "timeout"); - // The window resumes and drains the queue, exactly as `SlideApp::drain_control` does. - let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default()); - let ctx = egui::Context::default(); - let before = app.session.active().unwrap().doc.slides.len(); - let req = rx.try_recv().expect("the timed-out request is still queued"); - if req.take() { - let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req); - } - assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes"); - drop(rx); - } - - /// A request the app had already started when the reply timed out reports an unknown outcome, - /// not a definite failure (#719). - #[test] - fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() { - let (addr, rx) = server(small()); - let ui = std::thread::spawn(move || { - let req = rx.recv().expect("the request is queued"); - assert!(req.take(), "the app claims the request"); - // Still running it when the sender's wait expires. - std::thread::sleep(Duration::from_millis(450)); - let _ = req.reply.send(json!({"ok": true, "result": "ran"})); - }); - let (mut r, mut w) = client(addr); - writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap(); - let v = reply(&mut r).unwrap(); - assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}"); - ui.join().unwrap(); - } -} +mod tests; diff --git a/apps/deckcraft/src/control_server/tests.rs b/apps/deckcraft/src/control_server/tests.rs new file mode 100644 index 000000000..b83146557 --- /dev/null +++ b/apps/deckcraft/src/control_server/tests.rs @@ -0,0 +1,299 @@ +use super::*; + +/// A failed accept is followed by a wait before the next one, so a listener that keeps +/// failing does not spin, and the connections around the failures still come through. +#[test] +fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = super::accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); +} +use std::net::SocketAddr; +use std::time::Instant; + +fn small() -> Limits { + Limits { + max_clients: 2, + max_line: 64, + queue: 1, + idle_timeout: Duration::from_millis(300), + write_timeout: Duration::from_millis(300), + reply_timeout: Duration::from_millis(300), + } +} + +fn server(limits: Limits) -> (SocketAddr, Receiver) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap(); + (addr, serve_listener(l, limits, egui::Context::default())) +} + +fn client(addr: SocketAddr) -> (BufReader, TcpStream) { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + (BufReader::new(s.try_clone().unwrap()), s) +} + +fn reply(r: &mut BufReader) -> Option { + let mut s = String::new(); + match r.read_line(&mut s) { + Ok(0) | Err(_) => None, + Ok(_) => serde_json::from_str(&s).ok(), + } +} + +/// Answer every request with its method name until the server goes away. +fn ui(rx: Receiver) { + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "result": req.method})); + } + }); +} + +#[test] +fn request_round_trip() { + let (addr, rx) = server(small()); + ui(rx); + let (mut r, mut w) = client(addr); + writeln!(w, r#"{{"id":7,"method":"ui.inspect"}}"#).unwrap(); + let v = reply(&mut r).unwrap(); + assert_eq!(v["id"], 7); + assert_eq!(v["result"], "ui.inspect"); +} + +/// Every reply line until the server closes the connection. +fn replies_until_closed(r: &mut BufReader) -> Vec { + std::iter::from_fn(|| reply(r)).collect() +} + +/// A browser can POST to the port: its request line closes the connection before the JSON +/// line in its body is read, so nothing is dispatched. +#[test] +fn an_http_request_closes_the_connection_before_its_body_is_dispatched() { + let (addr, rx) = server(Limits { max_line: 512, ..small() }); + let (mut r, mut w) = client(addr); + w.write_all( + b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\ + {\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n", + ) + .unwrap(); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}"); + assert!(rx.try_recv().is_err(), "the body was dispatched"); +} + +#[test] +fn a_line_that_is_not_a_request_closes_the_connection() { + let (addr, rx) = server(small()); + ui(rx); + for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] { + let (mut r, mut w) = client(addr); + w.write_all(junk).unwrap(); + w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap(); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); + } + assert!(is_http_request_line("OPTIONS * HTTP/1.1")); + assert!(is_http_request_line("PRI * HTTP/2.0")); + assert!(!is_http_request_line("{\"method\":\"GET /\"}")); +} + +/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its +/// line was rejected. Closing with its second line unread resets the connection and discards +/// the reply already on its way, so the peer saw nothing at all; repeated here because it +/// depended on the timing of the two. +#[test] +fn a_pipelined_peer_still_reads_why_its_line_was_rejected() { + // A slot comes back only when the thread serving a peer ends, which can be after the next + // peer connects, so every peer has a slot of its own. + const PEERS: usize = 12; + let (addr, rx) = server(Limits { max_clients: PEERS, ..small() }); + for _ in 0..PEERS { + let (mut r, mut w) = client(addr); + w.write_all(b"hello\n").unwrap(); + // A second line, written after the first as a script writes it: it races the close. + let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert_eq!(replies[0]["ok"], false); + assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}"); + } + // The line after the rejected one is never dispatched, pipelined or not. + assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched"); +} + +/// A peer refused because every connection is taken still reads why when it had already +/// written a line: closing with that line unread resets the connection and discards the +/// reply. Repeated because it depends on the timing of the two. +#[test] +fn a_pipelined_peer_refused_as_busy_still_reads_why() { + let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + // Take the only slot, and make sure it is being served. + let (mut hr, mut hw) = client(addr); + writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + assert!(reply(&mut hr).is_some()); + for _ in 0..50 { + let (mut r, mut w) = client(addr); + let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n"); + let replies = replies_until_closed(&mut r); + assert_eq!(replies.len(), 1, "{replies:?}"); + assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}"); + } +} + +/// Peers refused as busy that stay connected without sending anything do not hold up the +/// connections after them: each one's drain runs to its deadline on a thread of its own, and +/// past `MAX_REFUSING` drains at once a refused connection is closed without one. +#[test] +fn silent_refused_peers_do_not_delay_the_next_refusal() { + let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + let (mut hr, mut hw) = client(addr); + writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + assert!(reply(&mut hr).is_some()); + // Twenty drains of 250 ms each, one after another, would take five seconds. + let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect(); + let start = Instant::now(); + let (mut r, _w) = client(addr); + let refused = reply(&mut r).unwrap(); + assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}"); + assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed()); + drop(silent); +} + +/// A peer that keeps its connection open after its line was rejected gives its slot back at +/// the drain deadline (the write timeout), not at the idle timeout. +#[test] +fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() { + let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + let (mut r, mut w) = client(addr); + w.write_all(b"hello\n").unwrap(); + assert_eq!(reply(&mut r).unwrap()["ok"], false); + // `w` stays open, sending nothing. + let deadline = Instant::now() + Duration::from_secs(4); + loop { + let (mut dr, mut dw) = client(addr); + writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); + if reply(&mut dr).is_some_and(|v| v["ok"] == true) { + break; + } + assert!(Instant::now() < deadline, "slot never freed"); + std::thread::sleep(Duration::from_millis(50)); + } + drop(w); +} + +#[test] +fn oversized_line_closes_connection() { + let (addr, rx) = server(small()); + ui(rx); + let (mut r, mut w) = client(addr); + w.write_all(&[b'x'; 200]).unwrap(); + let v = reply(&mut r).unwrap(); + assert!(v["error"].as_str().unwrap().contains("longer than 64")); + assert!(reply(&mut r).is_none()); +} + +#[test] +fn idle_connection_is_closed() { + let (addr, rx) = server(small()); + ui(rx); + let (mut r, mut w) = client(addr); + w.write_all(b"{\"id\":1,").unwrap(); // unterminated, then silence + let t = Instant::now(); + assert!(reply(&mut r).is_none()); + assert!(t.elapsed() < Duration::from_secs(4)); +} + +#[test] +fn connection_limit_refuses_then_frees_slot() { + // `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the + // third peer connected, and that peer then took its slot instead of reading `busy`. + let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() }); + ui(rx); + let a = client(addr); + let (mut br, mut bw) = client(addr); + // Make sure both are being served before the third arrives. + writeln!(bw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + assert!(reply(&mut br).is_some()); + let (mut cr, _cw) = client(addr); + let v = reply(&mut cr).unwrap(); + assert!(v["error"].as_str().unwrap().starts_with("busy")); + drop(a); + // The freed slot is reusable once the server notices the disconnect. + let deadline = Instant::now() + Duration::from_secs(4); + loop { + let (mut dr, mut dw) = client(addr); + writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap(); + if reply(&mut dr).is_some_and(|v| v["ok"] == true) { + break; + } + assert!(Instant::now() < deadline, "slot never freed"); + std::thread::sleep(Duration::from_millis(50)); + } +} + +#[test] +fn blocked_ui_times_out_and_full_queue_is_busy() { + let (addr, rx) = server(small()); + // No UI: the request that reaches the one-slot queue first fills it and times out; the + // other finds it full. Each connection has its own thread, so either request can be first. + let (mut ar, mut aw) = client(addr); + let (mut br, mut bw) = client(addr); + writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap(); + writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap(); + let a = reply(&mut ar).unwrap(); + let b = reply(&mut br).unwrap(); + assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2))); + let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()]; + errors.sort_unstable(); + assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}"); + drop(rx); +} + +/// A request that timed out while it was still queued must not run when the window resumes (#719). +#[test] +fn timed_out_request_does_not_run_when_the_window_resumes() { + let (addr, rx) = server(Limits { max_line: 512, ..small() }); + let (mut r, mut w) = client(addr); + writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap(); + let t = reply(&mut r).unwrap(); + assert_eq!(t["error"], "timeout"); + // The window resumes and drains the queue, exactly as `SlideApp::drain_control` does. + let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default()); + let ctx = egui::Context::default(); + let before = app.session.active().unwrap().doc.slides.len(); + let req = rx.try_recv().expect("the timed-out request is still queued"); + if req.take() { + let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req); + } + assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes"); + drop(rx); +} + +/// A request the app had already started when the reply timed out reports an unknown outcome, +/// not a definite failure (#719). +#[test] +fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() { + let (addr, rx) = server(small()); + let ui = std::thread::spawn(move || { + let req = rx.recv().expect("the request is queued"); + assert!(req.take(), "the app claims the request"); + // Still running it when the sender's wait expires. + std::thread::sleep(Duration::from_millis(450)); + let _ = req.reply.send(json!({"ok": true, "result": "ran"})); + }); + let (mut r, mut w) = client(addr); + writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap(); + let v = reply(&mut r).unwrap(); + assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}"); + ui.join().unwrap(); +} diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 605dc84a1..03859dc0a 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -187,7 +187,7 @@ fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. let logger = logging::install(); - if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { if let Err(e) = done { eprintln!("DeckCraft update helper failed: {e}"); std::process::exit(1); diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..3522a15b6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,7 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true allow-indexing-slicing-in-tests = true +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index 31dccab20..caf4daa46 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -68,3 +68,6 @@ sync_github_name = true [people.SofiaAFigueredo] real_name = "Sofia Alves Figueredo" + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/av1/src/header.rs b/crates/av1/src/header.rs index 85133df82..524e6d010 100644 --- a/crates/av1/src/header.rs +++ b/crates/av1/src/header.rs @@ -429,6 +429,38 @@ pub struct FrameHeader { pub spatial_id: u32, } +/// mark_ref_frames( idLen ): invalidates reference slots whose frame id is too far from the current one. +fn mark_ref_frames(st: &mut HeaderState, cur: u32, id_len: u32) { + let diff_len = st.seq.delta_frame_id_length_minus_2 + 2; + for rs in st.refs.iter_mut() { + if cur > (1 << diff_len) { + if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) { + rs.valid = false; + } + } else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) { + rs.valid = false; + } + } +} + +/// Skips buffer_removal_time for every operating point that includes this layer. +fn skip_buffer_removal_times(r: &mut BitReader, seq: &SequenceHeader, temporal_id: u32, spatial_id: u32) -> Result<()> { + if !(seq.decoder_model_info_present && r.flag()?) { + return Ok(()); + } + for op in 0..=seq.operating_points_cnt_minus_1 { + if seq.decoder_model_present_for_this_op[op] { + let idc = seq.operating_point_idc[op]; + let in_t = (idc >> temporal_id) & 1; + let in_s = (idc >> (spatial_id + 8)) & 1; + if idc == 0 || (in_t == 1 && in_s == 1) { + r.f(seq.buffer_removal_time_length_minus_1 + 1)?; + } + } + } + Ok(()) +} + impl FrameHeader { pub fn get_relative_dist(&self, seq: &SequenceHeader, a: u32, b: u32) -> i32 { relative_dist(seq, a, b) @@ -494,35 +526,10 @@ impl FrameHeader { } else { h.show_existing_frame = r.flag()?; if h.show_existing_frame { - h.frame_to_show_map_idx = r.f(3)? as usize; - if seq.decoder_model_info_present && !seq.equal_picture_interval { - r.f(seq.frame_presentation_time_length_minus_1 + 1)?; - } - if seq.frame_id_numbers_present { - r.f(id_len)?; - } - let rs = &st.refs[h.frame_to_show_map_idx]; - if !rs.valid { - return Err(Error::Invalid("show_existing_frame of an empty slot")); - } - h.frame_type = rs.frame_type; - if h.frame_type == KEY_FRAME as u8 { - h.refresh_frame_flags = all_frames; - } - if seq.film_grain_params_present { - h.film_grain = rs.grain.clone(); - } + h.parse_show_existing_frame(r, st, id_len)?; return Ok(h); } - h.frame_type = r.f(2)? as u8; - h.frame_is_intra = h.frame_type == INTRA_ONLY_FRAME as u8 || h.frame_type == KEY_FRAME as u8; - h.show_frame = r.flag()?; - if h.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval { - r.f(seq.frame_presentation_time_length_minus_1 + 1)?; - } - h.showable_frame = if h.show_frame { h.frame_type != KEY_FRAME as u8 } else { r.flag()? }; - h.error_resilient_mode = - if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { true } else { r.flag()? }; + h.parse_frame_type_and_flags(r, seq)?; } if h.frame_type == KEY_FRAME as u8 && h.show_frame { for rs in st.refs.iter_mut() { @@ -533,33 +540,11 @@ impl FrameHeader { h.order_hints[LAST_FRAME + i] = 0; } } - h.disable_cdf_update = r.flag()?; - h.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 { - r.flag()? - } else { - seq.seq_force_screen_content_tools != 0 - }; - if h.allow_screen_content_tools { - h.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 }; - } - if h.frame_is_intra { - h.force_integer_mv = true; - } + h.parse_screen_content_flags(r, seq)?; if seq.frame_id_numbers_present { h.current_frame_id = r.f(id_len)?; - // mark_ref_frames( idLen ) - let diff_len = seq.delta_frame_id_length_minus_2 + 2; - let cur = h.current_frame_id; - for rs in st.refs.iter_mut() { - if cur > (1 << diff_len) { - if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) { - rs.valid = false; - } - } else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) { - rs.valid = false; - } - } - st.current_frame_id = cur; + mark_ref_frames(st, h.current_frame_id, id_len); + st.current_frame_id = h.current_frame_id; } h.frame_size_override_flag = if h.frame_type == SWITCH_FRAME as u8 { true @@ -570,18 +555,7 @@ impl FrameHeader { }; h.order_hint = r.f(seq.order_hint_bits)?; h.primary_ref_frame = if h.frame_is_intra || h.error_resilient_mode { PRIMARY_REF_NONE } else { r.f(3)? as usize }; - if seq.decoder_model_info_present && r.flag()? { - for op in 0..=seq.operating_points_cnt_minus_1 { - if seq.decoder_model_present_for_this_op[op] { - let idc = seq.operating_point_idc[op]; - let in_t = (idc >> temporal_id) & 1; - let in_s = (idc >> (spatial_id + 8)) & 1; - if idc == 0 || (in_t == 1 && in_s == 1) { - r.f(seq.buffer_removal_time_length_minus_1 + 1)?; - } - } - } - } + skip_buffer_removal_times(r, seq, temporal_id, spatial_id)?; h.refresh_frame_flags = if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { all_frames } else { r.f(8)? }; if (!h.frame_is_intra || h.refresh_frame_flags != all_frames) && h.error_resilient_mode && seq.enable_order_hint { @@ -599,126 +573,16 @@ impl FrameHeader { h.allow_intrabc = r.flag()?; } } else { - let mut short = false; - if seq.enable_order_hint { - short = r.flag()?; - if short { - let last = r.f(3)? as usize; - let gold = r.f(3)? as usize; - h.set_frame_refs(seq, st.refs, last, gold); - } - } - for i in 0..REFS_PER_FRAME { - if !short { - h.ref_frame_idx[i] = r.f(3)? as usize; - } - if seq.frame_id_numbers_present { - r.f(seq.delta_frame_id_length_minus_2 + 2)?; - } - } - for i in 0..REFS_PER_FRAME { - if !st.refs[h.ref_frame_idx[i]].valid { - return Err(Error::Invalid("reference to an empty slot")); - } - } - if h.frame_size_override_flag && !h.error_resilient_mode { - // frame_size_with_refs( ) - let mut found = false; - for i in 0..REFS_PER_FRAME { - if r.flag()? { - let rs = &st.refs[h.ref_frame_idx[i]]; - h.upscaled_width = rs.upscaled_width; - h.frame_width = h.upscaled_width; - h.frame_height = rs.frame_height; - h.render_width = rs.render_width; - h.render_height = rs.render_height; - found = true; - break; - } - } - if !found { - h.frame_size(r, seq)?; - h.render_size(r)?; - } else { - h.superres_params(r, seq)?; - h.compute_image_size(); - } - } else { - h.frame_size(r, seq)?; - h.render_size(r)?; - } - h.allow_high_precision_mv = if h.force_integer_mv { false } else { r.flag()? }; - h.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 }; - h.is_motion_mode_switchable = r.flag()?; - h.use_ref_frame_mvs = if h.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? }; - for i in 0..REFS_PER_FRAME { - let rf = LAST_FRAME + i; - let hint = st.refs[h.ref_frame_idx[i]].order_hint; - h.order_hints[rf] = hint; - h.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, h.order_hint) > 0; - } + h.parse_inter_frame_refs(r, st)?; } h.disable_frame_end_update_cdf = if seq.reduced_still_picture_header || h.disable_cdf_update { true } else { r.flag()? }; - if h.primary_ref_frame == PRIMARY_REF_NONE { - // setup_past_independence( ) - st.seg_features = SegmentationFeatures::default(); - st.prev_gm_params = default_gm_params(); - st.lf_deltas = LoopFilterDeltas::defaults(); - h.lf.delta_enabled = true; - } else { - // load_previous( ) - let prev = &st.refs[h.ref_frame_idx[h.primary_ref_frame]]; - st.prev_gm_params = prev.gm_params; - st.lf_deltas = prev.lf_deltas; - st.seg_features = prev.seg_features; - } + h.setup_or_load_previous(st); h.prev_gm_params = st.prev_gm_params; h.tile_info(r, seq)?; h.quantization_params(r, seq)?; h.segmentation_params(r, st)?; - // delta_q_params( ) - if h.quant.base_q_idx > 0 { - h.delta_q_present = r.flag()?; - } - if h.delta_q_present { - h.delta_q_res = r.f(2)?; - } - // delta_lf_params( ) - if h.delta_q_present { - if !h.allow_intrabc { - h.delta_lf_present = r.flag()?; - } - if h.delta_lf_present { - h.delta_lf_res = r.f(2)?; - h.delta_lf_multi = r.flag()?; - } - } - h.coded_lossless = true; - for seg_id in 0..MAX_SEGMENTS { - let q = h.get_qindex(true, seg_id, 0); - let ll = q == 0 - && h.quant.delta_q_y_dc == 0 - && h.quant.delta_q_u_ac == 0 - && h.quant.delta_q_u_dc == 0 - && h.quant.delta_q_v_ac == 0 - && h.quant.delta_q_v_dc == 0; - h.lossless_array[seg_id] = ll; - if !ll { - h.coded_lossless = false; - } - if h.quant.using_qmatrix { - if ll { - h.seg_qm_level[0][seg_id] = 15; - h.seg_qm_level[1][seg_id] = 15; - h.seg_qm_level[2][seg_id] = 15; - } else { - h.seg_qm_level[0][seg_id] = h.quant.qm_y; - h.seg_qm_level[1][seg_id] = h.quant.qm_u; - h.seg_qm_level[2][seg_id] = h.quant.qm_v; - } - } - } - h.all_lossless = h.coded_lossless && h.frame_width == h.upscaled_width; + h.delta_q_lf_params(r)?; + h.compute_lossless(); h.loop_filter_params(r, seq, st)?; h.cdef_params(r, seq)?; h.lr_params(r, seq)?; @@ -739,6 +603,182 @@ impl FrameHeader { Ok(h) } + /// The show_existing_frame branch: picks the slot to show and inherits its frame type and grain. + fn parse_show_existing_frame(&mut self, r: &mut BitReader, st: &HeaderState, id_len: u32) -> Result<()> { + let seq = st.seq; + self.frame_to_show_map_idx = r.f(3)? as usize; + if seq.decoder_model_info_present && !seq.equal_picture_interval { + r.f(seq.frame_presentation_time_length_minus_1 + 1)?; + } + if seq.frame_id_numbers_present { + r.f(id_len)?; + } + let rs = &st.refs[self.frame_to_show_map_idx]; + if !rs.valid { + return Err(Error::Invalid("show_existing_frame of an empty slot")); + } + self.frame_type = rs.frame_type; + if self.frame_type == KEY_FRAME as u8 { + self.refresh_frame_flags = (1u32 << NUM_REF_FRAMES) - 1; + } + if seq.film_grain_params_present { + self.film_grain = rs.grain.clone(); + } + Ok(()) + } + + /// frame_type, show_frame, showable_frame and error_resilient_mode. + fn parse_frame_type_and_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> { + self.frame_type = r.f(2)? as u8; + self.frame_is_intra = self.frame_type == INTRA_ONLY_FRAME as u8 || self.frame_type == KEY_FRAME as u8; + self.show_frame = r.flag()?; + if self.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval { + r.f(seq.frame_presentation_time_length_minus_1 + 1)?; + } + self.showable_frame = if self.show_frame { self.frame_type != KEY_FRAME as u8 } else { r.flag()? }; + self.error_resilient_mode = + if self.frame_type == SWITCH_FRAME as u8 || (self.frame_type == KEY_FRAME as u8 && self.show_frame) { true } else { r.flag()? }; + Ok(()) + } + + /// disable_cdf_update, allow_screen_content_tools and force_integer_mv. + fn parse_screen_content_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> { + self.disable_cdf_update = r.flag()?; + self.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 { + r.flag()? + } else { + seq.seq_force_screen_content_tools != 0 + }; + if self.allow_screen_content_tools { + self.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 }; + } + if self.frame_is_intra { + self.force_integer_mv = true; + } + Ok(()) + } + + /// Reference selection, frame size, motion vector precision and filter flags of an inter frame. + fn parse_inter_frame_refs(&mut self, r: &mut BitReader, st: &HeaderState) -> Result<()> { + let seq = st.seq; + let mut short = false; + if seq.enable_order_hint { + short = r.flag()?; + if short { + let last = r.f(3)? as usize; + let gold = r.f(3)? as usize; + self.set_frame_refs(seq, st.refs, last, gold); + } + } + for i in 0..REFS_PER_FRAME { + if !short { + self.ref_frame_idx[i] = r.f(3)? as usize; + } + if seq.frame_id_numbers_present { + r.f(seq.delta_frame_id_length_minus_2 + 2)?; + } + } + for i in 0..REFS_PER_FRAME { + if !st.refs[self.ref_frame_idx[i]].valid { + return Err(Error::Invalid("reference to an empty slot")); + } + } + if self.frame_size_override_flag && !self.error_resilient_mode { + self.frame_size_with_refs(r, seq, st.refs)?; + } else { + self.frame_size(r, seq)?; + self.render_size(r)?; + } + self.allow_high_precision_mv = if self.force_integer_mv { false } else { r.flag()? }; + self.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 }; + self.is_motion_mode_switchable = r.flag()?; + self.use_ref_frame_mvs = if self.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? }; + for i in 0..REFS_PER_FRAME { + let rf = LAST_FRAME + i; + let hint = st.refs[self.ref_frame_idx[i]].order_hint; + self.order_hints[rf] = hint; + self.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, self.order_hint) > 0; + } + Ok(()) + } + + /// frame_size_with_refs( ). + fn frame_size_with_refs(&mut self, r: &mut BitReader, seq: &SequenceHeader, refs: &[RefInfo; NUM_REF_FRAMES]) -> Result<()> { + for i in 0..REFS_PER_FRAME { + if r.flag()? { + let rs = &refs[self.ref_frame_idx[i]]; + self.upscaled_width = rs.upscaled_width; + self.frame_width = self.upscaled_width; + self.frame_height = rs.frame_height; + self.render_width = rs.render_width; + self.render_height = rs.render_height; + self.superres_params(r, seq)?; + self.compute_image_size(); + return Ok(()); + } + } + self.frame_size(r, seq)?; + self.render_size(r) + } + + /// setup_past_independence( ) or load_previous( ), depending on primary_ref_frame. + fn setup_or_load_previous(&mut self, st: &mut HeaderState) { + if self.primary_ref_frame == PRIMARY_REF_NONE { + st.seg_features = SegmentationFeatures::default(); + st.prev_gm_params = default_gm_params(); + st.lf_deltas = LoopFilterDeltas::defaults(); + self.lf.delta_enabled = true; + } else { + let prev = &st.refs[self.ref_frame_idx[self.primary_ref_frame]]; + st.prev_gm_params = prev.gm_params; + st.lf_deltas = prev.lf_deltas; + st.seg_features = prev.seg_features; + } + } + + /// delta_q_params( ) and delta_lf_params( ). + fn delta_q_lf_params(&mut self, r: &mut BitReader) -> Result<()> { + if self.quant.base_q_idx > 0 { + self.delta_q_present = r.flag()?; + } + if self.delta_q_present { + self.delta_q_res = r.f(2)?; + if !self.allow_intrabc { + self.delta_lf_present = r.flag()?; + } + if self.delta_lf_present { + self.delta_lf_res = r.f(2)?; + self.delta_lf_multi = r.flag()?; + } + } + Ok(()) + } + + /// Per-segment lossless flags, quantizer matrix levels and the frame-wide lossless flags. + fn compute_lossless(&mut self) { + self.coded_lossless = true; + for seg_id in 0..MAX_SEGMENTS { + let q = self.get_qindex(true, seg_id, 0); + let ll = q == 0 + && self.quant.delta_q_y_dc == 0 + && self.quant.delta_q_u_ac == 0 + && self.quant.delta_q_u_dc == 0 + && self.quant.delta_q_v_ac == 0 + && self.quant.delta_q_v_dc == 0; + self.lossless_array[seg_id] = ll; + if !ll { + self.coded_lossless = false; + } + if self.quant.using_qmatrix { + let levels = if ll { [15; 3] } else { [self.quant.qm_y, self.quant.qm_u, self.quant.qm_v] }; + for (plane, level) in levels.into_iter().enumerate() { + self.seg_qm_level[plane][seg_id] = level; + } + } + } + self.all_lossless = self.coded_lossless && self.frame_width == self.upscaled_width; + } + fn superres_params(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> { self.use_superres = if seq.enable_superres { r.flag()? } else { false }; self.superres_denom = if self.use_superres { r.f(SUPERRES_DENOM_BITS as u32)? + SUPERRES_DENOM_MIN as u32 } else { SUPERRES_NUM as u32 }; diff --git a/crates/av1/src/inter.rs b/crates/av1/src/inter.rs index f14e59b96..ea3cdcd3d 100644 --- a/crates/av1/src/inter.rs +++ b/crates/av1/src/inter.rs @@ -420,20 +420,8 @@ impl TileDecoder<'_, '_> { let fh = &self.fs.fh; for ref_list in 0..1 + is_compound as usize { let ref_frame = cand_refs[ref_list]; - let ym = self.b.y_mode; - let global = - (ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION; - let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0; let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 }; - let use_warp = if w < 8 || h < 8 || fh.force_integer_mv { - 0 - } else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid { - 1 - } else if global && !self.is_scaled_ref(ref_idx) && global_valid { - 2 - } else { - 0 - }; + let use_warp = self.warp_mode(ref_frame, ref_idx, w, h); let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32]; if use_warp != 0 { let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] }; @@ -448,40 +436,7 @@ impl TileDecoder<'_, '_> { } } let ct = self.b.compound_type; - if ct == COMPOUND_WEDGE && plane == 0 { - let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign]; - for i in 0..h { - for j in 0..w { - self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32; - } - } - } else if ct == COMPOUND_INTRA { - let size_scale = MAX_SB_SIZE / h.max(w); - let im = self.b.interintra_mode; - for i in 0..h { - for j in 0..w { - self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED { - II_WEIGHTS_1D[i * size_scale] as i32 - } else if im == II_H_PRED { - II_WEIGHTS_1D[j * size_scale] as i32 - } else if im == II_SMOOTH_PRED { - II_WEIGHTS_1D[i.min(j) * size_scale] as i32 - } else { - 32 - }; - } - } - } else if ct == COMPOUND_DIFFWTD && plane == 0 { - for i in 0..h { - for j in 0..w { - let k = i * PRED_STRIDE + j; - let diff = (preds[0][k] - preds[1][k]).abs(); - let diff = round2(diff, (bd - 8) + rnd.2); - let m = (38 + diff / 16).clamp(0, 64); - self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m }; - } - } - } + self.fill_blend_mask(plane, w, h, &preds, rnd.2); let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) }; let max = (1i32 << bd) - 1; let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8; @@ -521,6 +476,64 @@ impl TileDecoder<'_, '_> { } } + /// Warp selection for one reference: 0 none, 1 local warp, 2 global warp. + fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 { + let fh = &self.fs.fh; + let ym = self.b.y_mode; + let global = + (ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION; + let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0; + if w < 8 || h < 8 || fh.force_integer_mv { + 0 + } else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid { + 1 + } else if global && !self.is_scaled_ref(ref_idx) && global_valid { + 2 + } else { + 0 + } + } + + /// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type. + fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec; 2], round_post: u32) { + let bd = self.fs.bit_depth; + let ct = self.b.compound_type; + if ct == COMPOUND_WEDGE && plane == 0 { + let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign]; + for i in 0..h { + for j in 0..w { + self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32; + } + } + } else if ct == COMPOUND_INTRA { + let size_scale = MAX_SB_SIZE / h.max(w); + let im = self.b.interintra_mode; + for i in 0..h { + for j in 0..w { + self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED { + II_WEIGHTS_1D[i * size_scale] as i32 + } else if im == II_H_PRED { + II_WEIGHTS_1D[j * size_scale] as i32 + } else if im == II_SMOOTH_PRED { + II_WEIGHTS_1D[i.min(j) * size_scale] as i32 + } else { + 32 + }; + } + } + } else if ct == COMPOUND_DIFFWTD && plane == 0 { + for i in 0..h { + for j in 0..w { + let k = i * PRED_STRIDE + j; + let diff = (preds[0][k] - preds[1][k]).abs(); + let diff = round2(diff, (bd - 8) + round_post); + let m = (38 + diff / 16).clamp(0, 64); + self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m }; + } + } + } + } + fn is_scaled_ref(&self, ref_idx: i32) -> bool { if ref_idx < 0 { return false; diff --git a/crates/av1/src/tile.rs b/crates/av1/src/tile.rs index 3c11e71f0..51eb22536 100644 --- a/crates/av1/src/tile.rs +++ b/crates/av1/src/tile.rs @@ -1682,6 +1682,87 @@ impl<'a, 'f> TileDecoder<'a, 'f> { } } + /// eob_pt, eob_extra and the remaining eob bits. + fn read_eob(&mut self, tx_sz: usize, tx_sz_ctx: usize, ptype: usize, tx_class: usize) -> usize { + let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4; + let ectx = (tx_class != TX_CLASS_2D) as usize; + let eob_pt = 1 + match eob_multisize { + 0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]), + 1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]), + 2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]), + 3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]), + 4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]), + 5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]), + _ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]), + }; + let mut eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 }; + let eob_shift = eob_pt as i32 - 3; + if eob_shift >= 0 { + let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]); + if extra == 1 { + eob += 1 << eob_shift; + } + for i in 1..(eob_pt as i32 - 2).max(0) { + let sh = (eob_pt as i32 - 2).max(0) - 1 - i; + if self.sd.read_literal(1) == 1 { + eob += 1 << sh; + } + } + } + eob + } + + /// Exp-Golomb coded remainder of a coefficient above the base range (read_golomb). + fn read_golomb(&mut self) -> u32 { + let mut length = 0; + loop { + length += 1; + if self.sd.read_literal(1) == 1 || length > 32 { + break; + } + } + let mut x: u32 = 1; + for _ in (0..length - 1).rev() { + x = (x << 1) | self.sd.read_literal(1); + } + x + } + + /// Reads the signs and Golomb remainders of the first `scan.len()` (= eob) coefficients, applies + /// them to `self.quant`, and returns the culLevel and dcCategory contexts. `blk4` is (x4, y4, w4, h4). + fn read_signs_and_golomb(&mut self, plane: usize, blk4: (usize, usize, usize, usize), scan: &[u16]) -> (u32, u8) { + let (x4, y4, w4, h4) = blk4; + let ptype = (plane > 0) as usize; + let mut cul_level = 0u32; + let mut dc_category = 0u8; + for (c, &p) in scan.iter().enumerate() { + let pos = p as usize; + let sign = if self.quant[pos] != 0 { + if c == 0 { + let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4); + self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1 + } else { + self.sd.read_literal(1) == 1 + } + } else { + false + }; + if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 { + let x = self.read_golomb(); + self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32; + } + if pos == 0 && self.quant[pos] > 0 { + dc_category = if sign { 1 } else { 2 }; + } + self.quant[pos] &= 0xFFFFF; + cul_level += self.quant[pos] as u32; + if sign { + self.quant[pos] = -self.quant[pos]; + } + } + (cul_level.min(63), dc_category) + } + /// coeffs( ): returns eob. fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize) -> usize { let x4 = start_x >> 2; @@ -1707,32 +1788,8 @@ impl<'a, 'f> TileDecoder<'a, 'f> { } self.plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4); let scan = self.get_scan(tx_sz); - let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4; let tx_class = get_tx_class(self.plane_tx_type); - let ectx = (tx_class != TX_CLASS_2D) as usize; - let eob_pt = 1 + match eob_multisize { - 0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]), - 1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]), - 2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]), - 3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]), - 4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]), - 5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]), - _ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]), - }; - eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 }; - let eob_shift = eob_pt as i32 - 3; - if eob_shift >= 0 { - let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]); - if extra == 1 { - eob += 1 << eob_shift; - } - for i in 1..(eob_pt as i32 - 2).max(0) { - let sh = (eob_pt as i32 - 2).max(0) - 1 - i; - if self.sd.read_literal(1) == 1 { - eob += 1 << sh; - } - } - } + eob = self.read_eob(tx_sz, tx_sz_ctx, ptype, tx_class); let adj = ADJUSTED_TX_SIZE[tx_sz] as usize; let bwl = TX_WIDTH_LOG2[adj] as usize; let height = TX_HEIGHT[adj] as usize; @@ -1780,45 +1837,7 @@ impl<'a, 'f> TileDecoder<'a, 'f> { lev[li] = level as u8; } self.levels = lev; - for c in 0..eob { - let pos = scan[c] as usize; - let sign = if self.quant[pos] != 0 { - if c == 0 { - let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4); - self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1 - } else { - self.sd.read_literal(1) == 1 - } - } else { - false - }; - if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 { - let mut length = 0; - loop { - length += 1; - if self.sd.read_literal(1) == 1 { - break; - } - if length > 32 { - break; - } - } - let mut x: u32 = 1; - for _ in (0..length - 1).rev() { - x = (x << 1) | self.sd.read_literal(1); - } - self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32; - } - if pos == 0 && self.quant[pos] > 0 { - dc_category = if sign { 1 } else { 2 }; - } - self.quant[pos] &= 0xFFFFF; - cul_level += self.quant[pos] as u32; - if sign { - self.quant[pos] = -self.quant[pos]; - } - } - cul_level = cul_level.min(63); + (cul_level, dc_category) = self.read_signs_and_golomb(plane, (x4, y4, w4, h4), &scan[..eob]); } for i in 0..w4 { if x4 + i < self.t.above_level[plane].len() { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 0b742f383..0a1202f74 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -16,6 +16,21 @@ use super::{ }; use crate::{Result, Session, Target}; +mod colors; +mod master_view; +mod slide_background; +mod slide_setup; + +use colors::{colors, effects, fonts, variant, variants}; +use master_view::{ + close_master, delete_layout, handout_placeholders, handout_view, in_handout, in_notes_master, insert_layout, insert_placeholder, master_view, + notes_placeholders, notes_view, placeholders, preserve, rename_layout, +}; +pub use master_view::{handout_placeholder, notes_placeholder}; +use slide_background::{background, hide_bg_graphics}; +pub use slide_setup::{civil, date_field_text, today}; +use slide_setup::{header_footer, scale_shapes, slide_size}; + pub fn specs() -> Vec { vec![ cmd!("design.theme", "Themes", ["Design", "Themes"], None, "{name} (see design.themes)", has_doc, theme), @@ -288,2150 +303,8 @@ fn themes(_s: &mut Session, _p: &Value) -> Result { })) } -fn colors(s: &mut Session, p: &Value) -> Result { - let name_v = p.get("name"); - let colors_v = p.get("colors"); - if name_v.is_none() && colors_v.is_none() { - let sc = current_colors(s)?; - s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); - return ok(); - } - let given = match name_v { - None => None, - Some(Value::String(n)) => { - let n = n.trim(); - if n.is_empty() { - return Err(bad("design.colors", "name is empty")); - } - Some(n.to_string()) - } - Some(_) => return Err(bad("design.colors", "`name` must be a string")), - }; - let slots = parse_color_slots(colors_v)?; - let current = current_colors(s)?; - let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; - if next == current { - return ok(); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); - m.theme.colors = next; - Ok(()) - })?; - ok() -} - -fn variant(s: &mut Session, p: &Value) -> Result { - let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); - let v = deckcraft_model::theme::theme_variants(&m.theme); - m.theme.colors = - v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; - Ok(()) - })?; - Ok(json!({"variant": index})) -} - -fn variants(s: &mut Session, _p: &Value) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); - let v = deckcraft_model::theme::theme_variants(&th); - let current = v.iter().position(|c| c.colors == th.colors.colors); - Ok(json!({ - "theme": th.name, - "current": current, - "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), - })) -} - -fn current_colors(s: &Session) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) -} - -fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { - let mut colors = serde_json::Map::new(); - for slot in deckcraft_color::SchemeSlot::THEME { - colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); - } - json!({"name": sc.name, "colors": colors}) -} - -/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such -/// as `bg1` are rejected because `ColorScheme::set` ignores them. -fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { - let Some(v) = colors_v else { return Ok(None) }; - let Some(map) = v.as_object() else { - return Err(bad("design.colors", "`colors` must be an object")); - }; - if map.is_empty() { - return Err(bad("design.colors", "`colors` is empty")); - } - let mut slots = Vec::with_capacity(map.len()); - for (k, v) in map { - let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { - return Err(bad("design.colors", format!("unknown colour `{k}`"))); - }; - if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { - return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); - } - let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { - return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); - }; - slots.push((slot, rgb)); - } - Ok(Some(slots)) -} - -/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name -/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. -fn next_scheme( - current: &deckcraft_color::ColorScheme, - given: Option<&str>, - slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, -) -> Result { - let mut next = current.clone(); - if let Some(name) = given { - if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { - next = builtin; - } else if slots.is_none() { - return Err(bad("design.colors", format!("no colour scheme `{name}`"))); - } - next.name = name.to_string(); - } - if let Some(changes) = slots { - for (slot, rgb) in changes { - next.set(*slot, *rgb); - } - if given.is_none() { - next.name = "Custom".into(); - } - } - Ok(next) -} - -fn fonts(s: &mut Session, p: &Value) -> Result { - let name_v = p.get("name"); - let major_v = p.get("major"); - let minor_v = p.get("minor"); - if name_v.is_none() && major_v.is_none() && minor_v.is_none() { - let scheme = current_fonts(s)?; - s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) }); - return ok(); - } - let given = match name_v { - None => None, - Some(Value::String(n)) => { - let n = n.trim(); - if n.is_empty() { - return Err(bad("design.fonts", "name is empty")); - } - Some(n.to_string()) - } - Some(_) => return Err(bad("design.fonts", "`name` must be a string")), - }; - let major = face_param("major", major_v)?; - let minor = face_param("minor", minor_v)?; - let current = current_fonts(s)?; - let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?; - if next == current { - return ok(); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next; - Ok(()) - })?; - ok() -} - -fn current_fonts(s: &Session) -> Result { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master")) -} - -fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value { - json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin}) -} - -/// `None` when the key is absent. A present face must be a non-blank string. -fn face_param(key: &str, v: Option<&Value>) -> Result> { - match v { - None => Ok(None), - Some(Value::String(s)) => { - let s = s.trim(); - if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) } - } - Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))), - } -} - -/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East -/// Asian, complex-script and per-script slots stay). The stored name is the given string when -/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name. -/// Faces without a name are a custom scheme: an omitted `minor` copies `major`. -fn next_font_scheme( - current: &deckcraft_model::theme::FontScheme, - given: Option<&str>, - major: Option<&str>, - minor: Option<&str>, -) -> Result { - let mut next = current.clone(); - if let Some(name) = given { - let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) { - next = builtin; - true - } else { - false - }; - if !replaced && major.is_none() && minor.is_none() { - return Err(bad("design.fonts", format!("no font scheme `{name}`"))); - } - if major.is_some() || minor.is_some() || !replaced { - next.name = name.to_string(); - } - } - if given.is_none() { - if let Some(face) = major { - next.major.latin = face.to_string(); - if minor.is_none() { - next.minor.latin = face.to_string(); - } - } - if let Some(face) = minor { - next.minor.latin = face.to_string(); - } - next.name = "Custom".into(); - } else { - if let Some(face) = major { - next.major.latin = face.to_string(); - } - if let Some(face) = minor { - next.minor.latin = face.to_string(); - } - } - Ok(next) -} - -fn effects(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?; - let scheme = builtin_format_schemes() - .into_iter() - .find(|f| f.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?; - let stored = scheme.name.clone(); - let same = { - let st = s.doc()?; - let mi = master_index(&st.doc, &st.selection); - st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme) - }; - if same { - return Ok(json!({"name": stored})); - } - s.edit(|doc, sel| { - let mi = master_index(doc, sel); - Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme; - Ok(()) - })?; - Ok(json!({"name": stored})) -} - -fn background(s: &mut Session, p: &Value) -> Result { - if bool_or(p, "copy", false) { - return copy_slide_background(s); - } - let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); - let transparency = f64_param(p, "transparency"); - let reset = bool_or(p, "reset", false); - let color = color_param(p, "color"); - let style = usize_param(p, "style"); - let op = if reset { - BgOp::Set(None) - } else if let Some(mut c) = color { - if let Some(t) = transparency { - set_solid_transparency(&mut c, t); - } - BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) - } else if let Some(i) = style { - // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). - let slots = - [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; - let i = i.clamp(1, 12) - 1; - BgOp::Set(Some(Background::Ref { - idx: 1001 + (i / 4) as u32, - color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), - })) - } else if let Some(g) = p.get("gradient") { - let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; - g.rotate_with_shape = false; - BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) - } else if let Some(pt) = p.get("pattern") { - BgOp::Set(Some(Background::Fill { - fill: Fill::Pattern(deckcraft_model::style::PatternFill { - preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), - fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), - bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), - }), - })) - } else if p.get("picture").is_some() { - let (name, bytes) = 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, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?; - let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; - if let Some(pp) = &props { - apply_picture_props(&mut pf, pp); - } - if let Some(t) = transparency { - set_picture_transparency(&mut pf, t); - } - BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) - } else if props.is_some() || transparency.is_some() { - BgOp::Adjust - } else { - return Err(bad( - "design.background", - "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", - )); - }; - let all = bool_or(p, "all", false); - let index = usize_param(p, "index"); - if matches!(op, BgOp::Adjust) { - let next = { - let st = s.doc()?; - let current = background_of(&st.doc, &st.selection, index); - let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); - let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); - if props.is_some() && !picture { - return Err(bad("design.background", "picture options need a picture background")); - } - if transparency.is_some() && !picture && !solid { - return Err(bad("design.background", "transparency needs a solid or picture background")); - } - let mut next = current.clone(); - if let Some(bg) = next.as_mut() { - adjust_background(bg, props.as_ref(), transparency); - } - if next == current { None } else { Some(next) } - }; - let Some(next) = next else { return ok() }; - s.edit(|doc, sel| { - write_background(doc, sel, index, false, next); - Ok(()) - })?; - return ok(); - } - let BgOp::Set(bg) = op else { return ok() }; - let unchanged = { - let st = s.doc()?; - backgrounds_already(&st.doc, &st.selection, index, all, &bg) - }; - if unchanged { - return ok(); - } - s.edit(|doc, sel| { - write_background(doc, sel, index, all, bg); - Ok(()) - })?; - ok() -} - -enum BgOp { - Set(Option), - Adjust, -} - -fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { - match sel.target { - Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()), - Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), - } -} - -fn used_master_indices(doc: &Presentation) -> BTreeSet { - let fallback = (!doc.masters.is_empty()).then_some(0); - doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect() -} - -fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { - match sel.target { - Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg), - Target::Slides => { - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)); - if all && bg.is_some() { - let masters = used_master_indices(doc); - let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg)); - let layouts_match = !masters.is_empty() - && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none()))); - slides_match && masters_match && layouts_match - } else { - slides_match - } - } - } -} - -fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { - match sel.target { - Target::Master { master } => { - if let Some(m) = doc.masters.get_mut(master) { - Arc::make_mut(m).background = bg; - } - } - Target::Layout { master, layout } => { - if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { - l.background = bg; - } - } - t @ (Target::Handout | Target::NotesMaster) => { - if let Some(m) = crate::page_master_mut(doc, t) { - Arc::make_mut(m).background = bg; - } - } - Target::Slides => { - if all && bg.is_some() { - for idx in used_master_indices(doc) { - if let Some(m) = doc.masters.get_mut(idx) { - let m = Arc::make_mut(m); - m.background = bg.clone(); - for l in &mut m.layouts { - l.background = None; - } - } - } - } - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; - for i in list { - if let Some(sl) = doc.slides.get_mut(i) { - Arc::make_mut(sl).background = bg.clone(); - } - } - } - } -} - -fn copy_slide_background(s: &mut Session) -> Result { - let bg = { - let st = s.doc()?; - if !matches!(st.selection.target, Target::Slides) { - return Err(bad("design.background", "copy applies to slides")); - } - let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); - let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg); - let masters_match = if bg.is_some() { - let masters = used_master_indices(&st.doc); - !masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg)) - } else { - true - }; - if slides_match && masters_match { - return ok(); - } - bg - }; - s.edit(|doc, sel| { - write_background(doc, sel, None, true, bg); - Ok(()) - })?; - ok() -} - -fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { - match bg { - Background::Fill { fill: Fill::Picture(pf) } => { - if let Some(pp) = props { - apply_picture_props(pf, pp); - } - if let Some(t) = transparency { - set_picture_transparency(pf, t); - } - } - Background::Fill { fill: Fill::Solid { color } } => { - if let Some(t) = transparency { - set_solid_transparency(color, t); - } - } - _ => {} - } -} - -fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { - let a = 1.0 - t.clamp(0.0, 1.0); - pf.alpha = (a < 1.0).then_some(a); -} - -fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { - color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); - let t = t.clamp(0.0, 1.0); - if t > 0.0 { - color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); - } -} - -/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). -fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { - use deckcraft_model::style::PictureMode; - let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { - pf.upright = !up; - } - match pp.get("tile").and_then(Value::as_bool) { - Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { - pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; - } - Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), - _ => {} - } - match &mut pf.mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - if let Some(v) = f("offsetX") { - *tx = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("offsetY") { - *ty = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("scaleX") { - *sx = v.clamp(0.01, 10.0); - } - if let Some(v) = f("scaleY") { - *sy = v.clamp(0.01, 10.0); - } - if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { - *flip = m.to_string(); - } - if let Some(a) = - pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) - { - *align = a.to_string(); - } - } - PictureMode::Stretch { fill_rect } => { - if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { - for (slot, v) in fill_rect.iter_mut().zip(arr) { - if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { - *slot = v.clamp(-10.0, 0.99); - } - } - } - } - } -} - -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() { - deckcraft_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 size = match str_param(p, "orientation") { - Some("portrait") if size.width > size.height => Size::new(size.height, size.width), - Some("landscape") if size.height > size.width => Size::new(size.height, size.width), - Some("portrait" | "landscape") | None => size, - Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))), - }; - let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); - s.edit(|doc, _| { - let old = doc.slide_size; - let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); - let (sx, sy, k, ox, oy) = match mode.as_str() { - "none" => (1.0, 1.0, 1.0, 0.0, 0.0), - "maximize" => { - let k = sx.max(sy); - (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) - } - _ => { - let k = sx.min(sy); - (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) - } - }; - // Masters and layouts stretch to the new size; slides scale by mode. - let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); - for m in &mut doc.masters { - let m = Arc::make_mut(m); - scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); - for l in &mut m.layouts { - scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); - } - } - for sl in &mut doc.slides { - scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); - } - doc.slide_size = size; - Ok(()) - })?; - Ok(json!({"w": size.width, "h": size.height})) -} - -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) = str_param(p, "dateFormat") { - // Refused rather than ignored: a format that renders nothing would leave the - // placeholder showing its saved text for ever. - if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { - return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); - } - hf.date_format = v.into(); - } - if let Some(v) = bool_param(p, "slideNumber") { - hf.slide_number = v; - } - if let Some(v) = bool_param(p, "footer") { - hf.footer = v; - } - if let Some(v) = str_param(p, "footerText") { - hf.footer_text = v.into(); - hf.footer = hf.footer || !v.is_empty(); - } - if let Some(v) = bool_param(p, "hideOnTitle") { - hf.hide_on_title = v; - } - let hf = hf.clone(); - let date_given = str_param(p, "dateText").is_some(); - let format_given = str_param(p, "dateFormat").is_some(); - let all = bool_or(p, "all", true); - let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; - for i in list { - let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; - let lay = doc.layout(layout).map(|(_, l)| l.clone()); - let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title); - let want = |k: PhType| { - !(hf.hide_on_title && is_title) - && match k { - PhType::Date => hf.date, - PhType::SlideNum => hf.slide_number, - PhType::Footer => hf.footer, - _ => false, - } - }; - let mut new_ids = vec![]; - for _ in 0..3 { - new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?)); - } - let Some(sl) = doc.slides.get_mut(i) else { continue }; - let sl = Arc::make_mut(sl); - for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { - let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); - match (has, want(k)) { - (Some(pos), false) => { - sl.shapes.remove(pos); - } - (None, true) => { - let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); - let Some(lp) = lp else { continue }; - let body = match k { - PhType::SlideNum => TextBody { - paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { - text: "‹#›".into(), - props: Default::default(), - kind: RunKind::Field { field: "slidenum".into() }, - }], - ..Default::default() - }], - ..Default::default() - }, - PhType::Date if hf.date_text.is_empty() => TextBody { - paragraphs: vec![deckcraft_model::Paragraph { - runs: vec![Run { - text: date_now(&hf.date_format), - props: Default::default(), - kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, - }], - ..Default::default() - }], - ..Default::default() - }, - PhType::Date => TextBody::from_text(&hf.date_text), - _ => TextBody::from_text(&hf.footer_text), - }; - sl.shapes.push(Shape { - id: new_ids.get(n).copied().unwrap_or_default(), - name: lp.name.clone(), - ph: lp.ph.clone(), - text: Some(body), - ..Default::default() - }); - } - (Some(pos), true) if k == PhType::Footer => { - if let Some(sh) = sl.shapes.get_mut(pos) { - sh.text = Some(TextBody::from_text(&hf.footer_text)); - } - } - (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { - if let Some(sh) = sl.shapes.get_mut(pos) { - set_date(sh, &hf.date_text, &hf.date_format); - } - } - _ => {} - } - } - } - Ok(()) - })?; - ok() -} - -/// The date field type `format` names, `datetime1` when it is empty or unknown. -fn date_kind(format: &str) -> &str { - if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } -} - -/// The saved text of a date field of `format`, as it shows now. -fn date_now(format: &str) -> String { - let kind = date_kind(format); - deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) -} - -/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty -/// `fixed`, at an automatic date field (one it already has keeps its format). -fn set_date(sh: &mut Shape, fixed: &str, format: &str) { - let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); - let field_now = runs().find_map(|r| match &r.kind { - RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), - _ => None, - }); - let has_field = field_now.is_some(); - let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); - let want = date_kind(format); - let (text, kind) = match fixed { - // Automatic: keep the field, but follow a format that has changed. - "" if field_now.as_deref() == Some(want) => return, - "" if has_field && format.is_empty() => return, - "" => (date_now(format), RunKind::Field { field: want.into() }), - _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, - _ => (fixed.to_string(), RunKind::Text), - }; - let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() }; - sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() }); -} - -/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date -/// field. Empty where there is no clock (wasm). -pub fn today() -> String { - date_field_text("datetime1") -} - -/// What date field `kind` shows now (empty if unknown or there is no clock). -pub fn date_field_text(kind: &str) -> String { - use deckcraft_text::datetime::{DateTime, field_text}; - DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() -} - -/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. -pub fn civil(z: i64) -> (i64, u32, u32) { - use chrono::Datelike; - // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 - // years (146_097 days). chrono converts the day within its cycle and whole cycles go back - // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and - // 0..146_097 days from it always fits. - let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); - match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { - Some(d) => { - let y = i128::from(d.year()) + i128::from(cycles) * 400; - (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) - } - None => (1970, 1, 1), - } -} - -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)); - // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects - // that layout. With neither key, follow the current slide's layout. - let layout = if p.get("master").is_some() && p.get("layout").is_none() { - None - } else { - usize_param(p, "layout") - .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) - }; - let target = match layout { - Some(l) => Target::Layout { master: mi, layout: l }, - None => Target::Master { master: mi }, - }; - s.select(|_, sel| { - sel.target = target; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(serde_json::to_value(target).unwrap_or_default()) -} - -fn in_handout(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) } -} - -/// The four handout-master placeholders PowerPoint offers, in a page of `size`. -pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { - let (w, h) = (size.width, size.height); - let (name, idx, x, y) = match kind { - PhType::Header => ("Header Placeholder", 0, 0.0, 0.0), - PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0), - PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95), - _ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95), - }; - let mut text = TextBody::default(); - if let Some(p) = text.paragraphs.first_mut() - && matches!(kind, PhType::Date | PhType::SlideNum) - { - p.props.align = Some(deckcraft_model::text::Align::Right); - } - Shape { - id, - name: format!("{name} {}", idx + 1), - xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)), - ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }), - text: Some(text), - ..Default::default() - } -} - -const HANDOUT_KINDS: [(&str, PhType); 4] = - [("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)]; - -fn handout_view(s: &mut Session, _p: &Value) -> Result { - let created = s.doc()?.doc.handout_master.is_none(); - if created { - s.edit(|doc, _| { - let mut m = deckcraft_model::Master { - name: "Handout Master".into(), - theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), - color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), - ..Default::default() - }; - let size = doc.notes_size; - for (_, k) in HANDOUT_KINDS { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?); - m.shapes.push(handout_placeholder(k, size, id)); - } - doc.handout_master = Some(Arc::new(m)); - Ok(()) - })?; - } - s.select(|_, sel| { - sel.target = Target::Handout; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(json!({"created": created})) -} - -fn handout_placeholders(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, _| { - let size = doc.notes_size; - let mut ids = vec![]; - for (key, _) in HANDOUT_KINDS { - let id = match p.get(key).and_then(Value::as_bool) { - Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)), - None => None, - }; - ids.push(id); - } - let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?); - for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) { - let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue }; - let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); - if on && !has { - m.shapes.push(handout_placeholder(kind, size, id)); - } else if !on { - m.shapes.retain(|x| x.ph_type() != Some(kind)); - } - } - Ok(()) - })?; - let m = s.doc()?.doc.handout_master.clone(); - let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k))); - Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) -} - -fn in_notes_master(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } -} - -const NOTES_KINDS: [(&str, PhType); 6] = [ - ("header", PhType::Header), - ("date", PhType::Date), - ("slideImage", PhType::SlideImage), - ("body", PhType::Body), - ("footer", PhType::Footer), - ("pageNumber", PhType::SlideNum), -]; - -/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. -pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { - let (w, h) = (size.width, size.height); - match kind { - PhType::SlideImage => Shape { - id, - name: "Slide Image Placeholder 3".into(), - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), - ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), - fill: Some(Fill::None), - line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), - ..Default::default() - }, - PhType::Body => { - let mut text = TextBody::default(); - text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; - Shape { - id, - name: "Notes Placeholder 4".into(), - xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), - ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), - text: Some(text), - ..Default::default() - } - } - _ => { - let mut s = handout_placeholder(kind, size, id); - if let Some(ph) = s.ph.as_mut() { - ph.idx = match kind { - PhType::Header => 0, - PhType::Date => 1, - PhType::Footer => 4, - _ => 5, - }; - } - s - } - } -} - -fn notes_view(s: &mut Session, _p: &Value) -> Result { - let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); - if created { - s.edit(|doc, _| { - let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { - name: "Notes Master".into(), - theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), - color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), - ..Default::default() - }); - let size = doc.notes_size; - for (_, k) in NOTES_KINDS { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?); - m.shapes.push(notes_placeholder(k, size, id)); - } - doc.notes_master = Some(Arc::new(m)); - Ok(()) - })?; - } - s.select(|_, sel| { - sel.target = Target::NotesMaster; - sel.shapes.clear(); - sel.text = None; - })?; - Ok(json!({"created": created})) -} - -fn notes_placeholders(s: &mut Session, p: &Value) -> Result { - s.edit(|doc, _| { - let size = doc.notes_size; - let ids: Vec = - NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; - let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); - for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { - let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; - let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); - if on && !has { - m.shapes.push(notes_placeholder(kind, size, id)); - } else if !on { - m.shapes.retain(|x| x.ph_type() != Some(kind)); - } - } - Ok(()) - })?; - let m = s.doc()?.doc.notes_master.clone(); - let mut out = serde_json::Map::new(); - for (key, kind) in NOTES_KINDS { - out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); - } - Ok(Value::Object(out)) -} - -fn close_master(s: &mut Session, _p: &Value) -> Result { - s.select(|_, sel| { - sel.target = Target::Slides; - 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 = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); - let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); - let size = doc.slide_size; - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); - let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { - t.id = tid; - t.text = Some(TextBody::default()); - t - }); - let _ = size; - let at = match sel.target { - Target::Layout { layout, .. } => layout + 1, - _ => m.layouts.len(), - }; - m.layouts.insert( - at.min(m.layouts.len()), - deckcraft_model::Layout { - id, - name, - kind: deckcraft_model::LayoutType::Custom, - shapes: title.into_iter().collect(), - background: None, - show_master_shapes: true, - preserve: true, - color_map: None, - raw_ext: None, - }, - ); - sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; - Ok(json!({"id": id})) - }) -} - -fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { - let st = s.doc()?; - let (m, l) = match st.selection.target { - Target::Layout { master, layout } => (master, layout), - _ => (master_index(&st.doc, &st.selection), 0), - }; - Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l))) -} - -fn rename_layout(s: &mut Session, p: &Value) -> Result { - let (mi, li) = layout_target(s, p, "master.renameLayout")?; - let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); - s.edit(|doc, _| { - let l = - doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; - l.name = name; - Ok(()) - })?; - ok() -} - -fn delete_layout(s: &mut Session, p: &Value) -> Result { - let (mi, li) = layout_target(s, p, "master.deleteLayout")?; - s.edit(|doc, sel| { - let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; - if doc.slides.iter().any(|x| x.layout == id) { - return Err(bad("master.deleteLayout", "slides use this layout")); - } - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); - if m.layouts.len() <= 1 { - return Err(bad("master.deleteLayout", "a master needs at least one layout")); - } - m.layouts.remove(li); - sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; - Ok(()) - })?; - ok() -} - -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(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); - let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; - let shape = Shape { - xfrm: Some(rect), - ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), - text: Some(TextBody::default()), - ..Default::default() - }; - let id = super::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; - Ok(json!({"id": id})) -} - -fn preserve(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let (on_master, mi, li, current) = match st.selection.target { - Target::Master { master } => { - let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; - (true, master, 0, on) - } - Target::Layout { master, layout } => { - let on = st - .doc - .masters - .get(master) - .and_then(|m| m.layouts.get(layout)) - .map(|l| l.preserve) - .ok_or_else(|| bad("master.preserve", "no such layout"))?; - (false, master, layout, on) - } - Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")), - }; - let next = bool_param(p, "preserve").unwrap_or(!current); - if next == current { - return Ok(json!({"preserve": next})); - } - s.edit(|doc, _| { - if on_master { - let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); - m.preserve = next; - } else { - let l = - doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; - l.preserve = next; - } - Ok(()) - })?; - Ok(json!({"preserve": next})) -} - -fn placeholders(s: &mut Session, p: &Value) -> Result { - let title = bool_param(p, "title"); - let footers = bool_param(p, "footers"); - if title.is_none() && footers.is_none() { - return Err(bad("master.placeholders", "give title or footers")); - } - let st = s.doc()?; - if matches!(st.selection.target, Target::Slides) { - return Err(bad("master.placeholders", "open Slide Master view")); - } - let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); - let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); - let title_changes = title.is_some_and(|want| want != has_title); - let footer_changes = footers.is_some_and(|want| want != has_footers); - if !title_changes && !footer_changes { - return Ok(json!({"title": has_title, "footers": has_footers})); - } - let mut add = Vec::new(); - if title == Some(true) && !has_title { - add.push(PhType::Title); - } - if footers == Some(true) && !has_footers { - add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); - } - let size = st.doc.slide_size; - s.edit(|doc, sel| { - let mut ids = Vec::with_capacity(add.len()); - for _ in 0..add.len() { - ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?)); - } - let prototypes = match sel.target { - Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), - Target::Slides => Vec::new(), - Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")), - }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; - if title == Some(false) { - shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); - } - if footers == Some(false) { - shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); - } - for (kind, id) in add.into_iter().zip(ids) { - let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); - let proto = prototypes.iter().find(|sh| match kind { - PhType::Title => sh.ph_type().is_some_and(PhType::is_title), - other => sh.ph_type() == Some(other), - }); - let shape = match proto { - Some(src) => retarget_placeholder(src.clone(), id, idx), - None => synthesized_placeholder(kind, id, idx, size), - }; - shapes.push(shape); - } - Ok(()) - })?; - let st = s.doc()?; - let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); - let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); - Ok(json!({"title": title, "footers": footers})) -} - -fn preferred_ph_idx(kind: PhType) -> u32 { - match kind { - PhType::Title | PhType::CtrTitle => 0, - PhType::Date => 10, - PhType::Footer => 11, - PhType::SlideNum => 12, - _ => 1, - } -} - -fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { - let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); - if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } -} - -/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide -/// number keeps its `‹#›` field. -fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { - sh.id = id; - if let Some(ph) = sh.ph.as_mut() { - ph.idx = idx; - } - let slide_num = sh.ph_type() == Some(PhType::SlideNum); - if slide_num { - let paras = vec![slide_number_paragraph()]; - if let Some(text) = sh.text.as_mut() { - text.paragraphs = paras; - } else { - sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); - } - } else if let Some(text) = sh.text.as_mut() { - text.paragraphs.clear(); - } - sh -} - -fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { - let (name, text) = match kind { - PhType::Date => ("Date Placeholder", TextBody::default()), - PhType::Footer => ("Footer Placeholder", TextBody::default()), - PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), - _ => ("Title", TextBody::default()), - }; - Shape { - id, - name: name.into(), - xfrm: Some(placeholder_rect(kind, size)), - ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), - text: Some(text), - ..Default::default() - } -} - -/// Standard placeholder boxes at 960×540, scaled to the slide size. -fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { - let sx = size.width / 960.0; - let sy = size.height / 540.0; - let (x, y, w, h) = match kind { - PhType::Date => (60.0, 500.0, 216.0, 28.0), - PhType::Footer => (318.0, 500.0, 324.0, 28.0), - PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), - _ => (60.0, 29.0, 840.0, 104.0), - }; - deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) -} - -fn slide_number_paragraph() -> deckcraft_model::Paragraph { - deckcraft_model::Paragraph { - runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], - ..Default::default() - } -} +#[cfg(test)] +mod tests; #[cfg(test)] -mod tests { - use std::sync::Arc; - - use deckcraft_color::SchemeSlot; - use deckcraft_model::style::PictureMode; - use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes}; - use deckcraft_model::{Background, Fill}; - use serde_json::{Value, json}; - - use crate::{Session, UiRequest}; - - fn scheme(s: &Session) -> deckcraft_color::ColorScheme { - s.doc().unwrap().doc.masters[0].theme.colors.clone() - } - - fn font_scheme(s: &Session) -> FontScheme { - s.doc().unwrap().doc.masters[0].theme.fonts.clone() - } - - fn undo_len(s: &Session) -> usize { - s.doc().unwrap().history.undo.len() - } - - fn png() -> String { - let mut img = image::RgbaImage::new(2, 2); - img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255])); - let mut bytes = Vec::new(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); - crate::cmd::base64_encode(&bytes) - } - - fn picture(s: &Session) -> deckcraft_model::style::PictureFill { - match &s.doc().unwrap().doc.slides[0].background { - Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(), - other => panic!("expected a picture background, got {other:?}"), - } - } - - #[test] - fn layout_commands_reject_invalid_index() { - let mut s = Session::with_new(); - s.execute("master.insertLayout", &json!({})).unwrap(); - let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::(); - let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::>(); - let n = count(&s); - let before = names(&s); - for bad in [json!(-1), json!(1.5)] { - assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err()); - assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err()); - assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err()); - assert_eq!(count(&s), n); - assert_eq!(names(&s), before); - } - s.execute("master.deleteLayout", &json!({})).unwrap(); - assert_eq!(count(&s), n - 1); - } - - #[test] - fn civil_dates() { - assert_eq!(super::civil(0), (1970, 1, 1)); - assert_eq!(super::civil(20_734), (2026, 10, 8)); - // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. - assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); - assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); - assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); - assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); - } - - #[test] - fn colors_dialog_apply_and_reject() { - let mut s = Session::with_new(); - let original = scheme(&s); - let n = undo_len(&s); - s.execute("design.colors", &json!({})).unwrap(); - s.execute("design.colors", &Value::Null).unwrap(); - assert_eq!(scheme(&s), original); - assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); - assert_eq!(s.ui_requests.len(), 2); - match &s.ui_requests[0] { - UiRequest::Dialog { id, params } => { - assert_eq!(id, "themeColors"); - assert_eq!(params["name"], "DeckCraft"); - let colors = params["colors"].as_object().unwrap(); - assert_eq!(colors.len(), 12); - assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex())); - assert!(colors.contains_key("folHlink")); - } - other => panic!("expected a dialog, got {other:?}"), - } - - s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); - let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap(); - assert_eq!(scheme(&s).name, "Harbor"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); - s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); - - let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); - assert!(err.contains("no colour scheme `Nope`"), "{err}"); - assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); - assert!(s.execute("design.colors", &json!({"name": 1})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); - assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); - assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); - assert_eq!(scheme(&s).name, "Harbor"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 1); - - s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); - assert_eq!(scheme(&s).name, "Custom"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); - assert_eq!(undo_len(&s), n + 2); - - let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); - s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); - assert_eq!(scheme(&s).name, "Meadow"); - assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); - assert_eq!(undo_len(&s), n + 3); - - s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); - assert_eq!(scheme(&s).name, "Talk"); - assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); - assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203"); - assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); - - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(scheme(&s).name, "Meadow"); - assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); - } - - #[test] - fn fonts_dialog_apply_and_reject() { - let mut s = Session::with_new(); - let original = font_scheme(&s); - let n = undo_len(&s); - s.execute("design.fonts", &json!({})).unwrap(); - s.execute("design.fonts", &Value::Null).unwrap(); - s.execute("design.fonts", &json!([1, 2])).unwrap(); - assert_eq!(font_scheme(&s), original); - assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); - assert_eq!(s.ui_requests.len(), 3); - match &s.ui_requests[0] { - UiRequest::Dialog { id, params } => { - assert_eq!(id, "themeFonts"); - assert_eq!(params["name"], original.name); - assert_eq!(params["major"], original.major.latin); - assert_eq!(params["minor"], original.minor.latin); - } - other => panic!("expected a dialog, got {other:?}"), - } - - s.execute("design.fonts", &json!({"name": "classic"})).unwrap(); - let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap(); - assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name"); - assert_eq!(undo_len(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); - s.execute("design.fonts", &json!({"name": "Classic"})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); - - let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string(); - assert!(err.contains("no font scheme `Nope`"), "{err}"); - assert!(s.execute("design.fonts", &json!({"name": " "})).is_err()); - assert!(s.execute("design.fonts", &json!({"name": 1})).is_err()); - assert!(s.execute("design.fonts", &json!({"major": ""})).is_err()); - assert!(s.execute("design.fonts", &json!({"major": " "})).is_err()); - assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err()); - assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err()); - assert_eq!(font_scheme(&s), classic); - assert_eq!(undo_len(&s), n + 1); - - s.edit(|doc, _| { - let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts; - fonts.major.ea = "EA Major".into(); - fonts.minor.cs = "CS Minor".into(); - fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() }); - Ok(()) - }) - .unwrap(); - let marked = font_scheme(&s); - let n = undo_len(&s); - - s.execute("design.fonts", &json!({"major": " Lato "})).unwrap(); - let custom = font_scheme(&s); - assert_eq!(custom.name, "Custom"); - assert_eq!(custom.major.latin, "Lato"); - assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major"); - assert_eq!(custom.major.ea, "EA Major"); - assert_eq!(custom.minor.cs, "CS Minor"); - assert_eq!(custom.major.scripts, marked.major.scripts); - assert_eq!(undo_len(&s), n + 1); - - s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Custom"); - assert_eq!(font_scheme(&s).major.latin, "Lato"); - assert_eq!(font_scheme(&s).minor.latin, "Roboto"); - assert_eq!(font_scheme(&s).major.ea, "EA Major"); - assert_eq!(undo_len(&s), n + 2); - - s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Classic"); - assert_eq!(font_scheme(&s).major.latin, "Inter"); - assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); - assert_eq!(font_scheme(&s).major.ea, ""); - assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); - assert_eq!(undo_len(&s), n + 3); - - s.edit(|doc, _| { - std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into(); - Ok(()) - }) - .unwrap(); - s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap(); - assert_eq!(font_scheme(&s).name, "Talk"); - assert_eq!(font_scheme(&s).major.latin, "Inter"); - assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans"); - assert_eq!(font_scheme(&s).major.ea, "Kept"); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); - - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(font_scheme(&s).name, "Classic"); - assert_eq!(font_scheme(&s).major.ea, "Kept"); - assert_eq!(font_scheme(&s).minor.latin, "Open Sans"); - } - - #[test] - fn background_picture_options_tile_transparency_and_copy() { - let mut s = Session::with_new(); - let n = undo_len(&s); - let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string(); - assert!(err.contains("picture background"), "{err}"); - assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err()); - assert_eq!(undo_len(&s), n); - - s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap(); - match &s.doc().unwrap().doc.slides[0].background { - Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()), - other => panic!("expected a solid background, got {other:?}"), - } - let n = undo_len(&s); - s.execute("design.background", &json!({"transparency": 0.25})).unwrap(); - assert_eq!(undo_len(&s), n, "the same transparency is not an undo step"); - - s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap(); - let pf = picture(&s); - assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6); - assert!(matches!(pf.mode, PictureMode::Tile { .. })); - let n = undo_len(&s); - - s.execute( - "design.background", - &json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}), - ) - .unwrap(); - match picture(&s).mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0)); - assert_eq!(flip, "xy"); - assert_eq!(align, "ctr"); - } - other => panic!("expected a tile, got {other:?}"), - } - assert!(picture(&s).upright); - s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap(); - match picture(&s).mode { - PictureMode::Tile { tx, align, .. } => { - assert_eq!(tx, 12.0); - assert_eq!(align, "ctr"); - } - other => panic!("expected the tile to stay, got {other:?}"), - } - assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step"); - - s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap(); - match picture(&s).mode { - PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]), - other => panic!("expected a stretch, got {other:?}"), - } - - s.execute("slide.new", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.slides[1].background.is_none()); - s.execute("slide.go", &json!({"index": 0})).unwrap(); - let n = undo_len(&s); - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background); - assert_eq!(undo_len(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background"); - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step"); - } - - #[test] - fn background_all_applies_to_master_and_new_slides_inherit() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); - assert_eq!(s0["background"], "#121826"); - - let n = undo_len(&s); - s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1); - - // Idempotency: re-applying the same background to all is a no-op (no extra undo step) - s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step"); - - // Slide 0 has new background, master has new background, layouts cleared - let doc = &s.doc().unwrap().doc; - assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. }))); - assert_eq!(doc.masters[0].background, doc.slides[0].background); - for l in &doc.masters[0].layouts { - assert!(l.background.is_none()); - } - - // Newly added slides inherit the master background (fixes #891) - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap(); - let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); - let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap(); - assert_eq!(s0_after["background"], "#0D0D0D"); - assert_eq!(s1_after["background"], "#0D0D0D"); - - // Undo restores previous slide and master backgrounds - s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new - assert_eq!(s.doc().unwrap().doc.slides.len(), 1); - s.execute("edit.undo", &json!({})).unwrap(); // undos design.background - let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); - assert_eq!(s0_reverted["background"], "#121826"); - } - - #[test] - fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); - - let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) }; - s.edit(|doc, _| { - let m = Arc::make_mut(&mut doc.masters[0]); - m.layouts[0].background = Some(custom_layout_bg.clone()); - Ok(()) - }) - .unwrap(); - - // Slide 0 follows master (background: None) - assert!(s.doc().unwrap().doc.slides[0].background.is_none()); - // Give Slide 1 an explicit override - s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap(); - assert!(s.doc().unwrap().doc.slides[1].background.is_some()); - - let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); - assert!(orig_master_bg.is_some()); - - // Select slide 0 (which follows master) and apply copy to all - s.execute("slide.go", &json!({"index": 0})).unwrap(); - let n = undo_len(&s); - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1); - - // Slide 1 has been reset to None (follows master), slide 0 remains None - let doc = &s.doc().unwrap().doc; - assert!(doc.slides[0].background.is_none()); - assert!(doc.slides[1].background.is_none()); - - // Master and layout backgrounds survived! - assert_eq!(doc.masters[0].background, orig_master_bg); - assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); - - // Idempotency: re-applying copy is a no-op - s.execute("design.background", &json!({"copy": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op"); - } - - #[test] - fn background_reset_all_preserves_master_and_layout_backgrounds() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); - - let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) }; - s.edit(|doc, _| { - let m = Arc::make_mut(&mut doc.masters[0]); - m.layouts[0].background = Some(custom_layout_bg.clone()); - Ok(()) - }) - .unwrap(); - - let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); - assert!(orig_master_bg.is_some()); - - // Set explicit backgrounds on both slides individually (master remains untouched) - s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap(); - s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap(); - assert!(s.doc().unwrap().doc.slides[0].background.is_some()); - assert!(s.doc().unwrap().doc.slides[1].background.is_some()); - - // Now perform reset with all - let n = undo_len(&s); - s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1); - - // Slides reset to None to follow master - let doc = &s.doc().unwrap().doc; - assert!(doc.slides[0].background.is_none()); - assert!(doc.slides[1].background.is_none()); - - // Master and layout backgrounds survived! - assert_eq!(doc.masters[0].background, orig_master_bg); - assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); - - // Idempotency: re-running reset all is a no-op - s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op"); - } - - #[test] - fn background_all_only_touches_used_masters() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - - // Add a second master to presentation - let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) }; - s.edit(|doc, _| { - let mut m2 = (*doc.masters[0]).clone(); - m2.id = deckcraft_model::MasterId(999); - m2.background = Some(second_master_bg.clone()); - doc.masters.push(Arc::new(m2)); - Ok(()) - }) - .unwrap(); - - assert_eq!(s.doc().unwrap().doc.masters.len(), 2); - // Slides only use master 0 (default layout) - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - - let doc = &s.doc().unwrap().doc; - // Master 0 updated - assert_eq!(doc.masters[0].background, doc.slides[0].background); - // Master 1 preserved! - assert_eq!(doc.masters[1].background, Some(second_master_bg)); - } - - #[test] - fn background_all_clears_layout_overrides_even_if_slides_and_master_match() { - let mut s = Session::with_new(); - s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); - - // Set all slides and master to #AA0000 - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background); - - // Add a layout override on layout 0 - let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) }; - s.edit(|doc, _| { - let m = Arc::make_mut(&mut doc.masters[0]); - m.layouts[0].background = Some(custom_bg); - Ok(()) - }) - .unwrap(); - - // Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override. - let n = undo_len(&s); - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op"); - - let doc = &s.doc().unwrap().doc; - assert!(doc.masters[0].layouts[0].background.is_none()); - - // Now that layout override is cleared, repeating is an actual no-op - s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); - assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); - } -} - -#[cfg(test)] -mod browse_theme_tests { - use std::sync::Arc; - - use deckcraft_model::text::RunKind; - use deckcraft_model::{Background, Fill, PhType}; - use serde_json::json; - - use crate::{Session, Target}; - - #[test] - fn applies_the_theme_of_another_deck_and_undoes() { - let mut other = Session::with_new(); - other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap(); - other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap(); - let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); - let before = s.doc().unwrap().doc.masters[0].theme.clone(); - s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); - let t = &s.doc().unwrap().doc.masters[0].theme; - assert_eq!(t.fonts.major.latin, "Lato"); - assert_eq!(t.fonts.minor.latin, "Source Sans 3"); - assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456"); - assert!(s.doc().unwrap().doc.validate().is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.masters[0].theme, before); - assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err()); - assert!(s.execute("design.browseTheme", &json!({})).is_err()); - } - - /// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides - /// can only find them by kind) and its content layout's title moved. - fn brand_template() -> (String, Vec, deckcraft_model::Xfrm) { - let mut img = image::RgbaImage::new(2, 2); - img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255])); - let mut logo = Vec::new(); - image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap(); - let mut t = Session::with_new(); - t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap(); - let title = t - .edit(|doc, _| { - let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap(); - let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap()); - let m = Arc::make_mut(doc.masters.get_mut(0).unwrap()); - m.shapes.push(deckcraft_model::Shape { - id, - name: "Logo".into(), - kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, - xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }), - ..Default::default() - }); - m.background = - Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) }); - for l in &mut m.layouts { - l.name = format!("Brand {}", l.name); - } - let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap(); - let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); - let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() }; - title.xfrm = Some(x); - Ok(x) - }) - .unwrap(); - (crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title) - } - - #[test] - fn a_template_brings_its_master_and_layouts_and_slides_follow() { - let (data, logo, title) = brand_template(); - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let n = undo_len(&s); - let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap(); - assert_eq!(r["slides"], json!(2), "{r}"); - assert_eq!(undo_len(&s), n + 1, "one undo step"); - - let doc = &s.doc().unwrap().doc; - assert!(doc.validate().is_empty(), "{:?}", doc.validate()); - assert_eq!(doc.masters.len(), 1, "the old master is replaced"); - let m = &doc.masters[0]; - assert_ne!(m.id, before.masters[0].id); - assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand "))); - assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } }))); - let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along"); - let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") }; - assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo)); - // The 4:3 master stretches to this deck's 16:9. - let k = doc.slide_size.width / 720.0; - let px = pic.xfrm.unwrap(); - assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}"); - - // Each slide moved to the brand layout of its kind, and kept its text. - for (old, new) in before.slides.iter().zip(&doc.slides) { - let (_, was) = before.layout(old.layout).unwrap(); - let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master"); - assert_eq!(owner.id, m.id); - assert_eq!(now.kind, was.kind); - } - let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap(); - assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one"))); - let (_, lay) = doc.layout(agenda.layout).unwrap(); - let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap(); - assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}"); - let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); - assert!(st.xfrm.is_none(), "the slide title follows the new layout"); - - s.execute("edit.undo", &json!({})).unwrap(); - let doc = &s.doc().unwrap().doc; - assert_eq!(doc.masters, before.masters); - assert_eq!(doc.slides, before.slides); - } - - #[test] - fn a_preserved_master_stays_beside_the_template() { - let (data, _, _) = brand_template(); - let mut s = Session::with_new(); - s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); - s.execute("master.preserve", &json!({"preserve": true})).unwrap(); - s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); - s.execute("master.preserve", &json!({"preserve": true})).unwrap(); - let old = s.doc().unwrap().doc.masters[0].id; - s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); - s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); - let st = s.doc().unwrap(); - assert_eq!(st.doc.masters.len(), 2); - assert_eq!(st.doc.masters[1].id, old); - assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master"); - let new = &st.doc.masters[0]; - assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some())); - } - - fn undo_len(s: &crate::Session) -> usize { - s.doc().unwrap().history.undo.len() - } - - #[test] - fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() { - let mut s = crate::Session::with_new(); - let listed = s.execute("design.themes", &json!({})).unwrap(); - let names: Vec = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); - assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]); - - let n = undo_len(&s); - s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap(); - assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step"); - let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string(); - assert!(err.contains("no effect scheme"), "{err}"); - assert!(s.execute("design.effects", &json!({})).is_err()); - assert_eq!(undo_len(&s), n); - - s.execute("design.theme", &json!({"name": "Harbor"})).unwrap(); - let before = s.doc().unwrap().doc.masters[0].theme.clone(); - s.execute("design.effects", &json!({"name": "soft"})).unwrap(); - let theme = s.doc().unwrap().doc.masters[0].theme.clone(); - assert_eq!(theme.name, "Harbor"); - assert_eq!(theme.colors, before.colors); - assert_eq!(theme.fonts, before.fonts); - assert_eq!(theme.format.name, "Soft"); - assert!(theme.format.effects[0].outer_shadow.is_some()); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects"); - - let n = undo_len(&s); - s.execute("design.effects", &json!({"name": "Soft"})).unwrap(); - assert_eq!(undo_len(&s), n, "the same scheme is not an undo step"); - - s.execute("design.effects", &json!({"name": "Glow"})).unwrap(); - let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone(); - assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6)); - s.execute("design.effects", &json!({"name": "Plain"})).unwrap(); - let plain = s.doc().unwrap().doc.masters[0].theme.format.clone(); - assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none())); - assert_eq!(plain.lines[2].width, Some(0.75)); - } - - fn shapes(s: &Session) -> Vec { - s.doc().unwrap().shapes().to_vec() - } - - fn has_title(s: &Session) -> bool { - shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)) - } - - fn has_footers(s: &Session) -> bool { - shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)) - } - - #[test] - fn preserve_and_placeholders_edit_the_open_master_or_layout() { - for id in ["master.preserve", "master.placeholders"] { - assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}"); - } - - let mut s = Session::with_new(); - let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); - - s.execute("view.slideMaster", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 }); - s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 }); - assert!(!s.doc().unwrap().doc.masters[0].preserve); - - let n = s.doc().unwrap().history.undo.len(); - s.execute("master.preserve", &json!({"preserve": false})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step"); - s.execute("master.preserve", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.masters[0].preserve); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!s.doc().unwrap().doc.masters[0].preserve); - - s.execute("master.placeholders", &json!({"footers": false})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); - assert!(has_title(&s)); - assert!(!has_footers(&s)); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(has_footers(&s)); - - s.execute("view.closeMaster", &json!({})).unwrap(); - let n = s.doc().unwrap().history.undo.len(); - let err = s.execute("master.preserve", &json!({})).unwrap_err(); - assert!(err.to_string().contains("open Slide Master view"), "{err}"); - let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err(); - assert!(err.to_string().contains("open Slide Master view"), "{err}"); - let err = s.execute("master.placeholders", &json!({})).unwrap_err(); - assert!(err.to_string().contains("give title or footers"), "{err}"); - assert_eq!(s.doc().unwrap().history.undo.len(), n); - - s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); - assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); - assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - assert!(!has_title(&s)); - assert!(has_footers(&s)); - - let n = s.doc().unwrap().history.undo.len(); - s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state"); - - s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); - assert!(has_title(&s)); - assert!(!has_footers(&s)); - let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); - assert_eq!(title.name, "Title 1"); - assert_eq!(title.ph.unwrap().idx, 0); - assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!has_title(&s)); - assert!(has_footers(&s)); - - s.execute("master.placeholders", &json!({"footers": false})).unwrap(); - s.execute("master.placeholders", &json!({"footers": true})).unwrap(); - let restored = shapes(&s); - assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); - assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); - let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); - let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0]; - assert_eq!(run.text, "‹#›"); - assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum")); - - assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - s.execute("master.preserve", &json!({"preserve": false})).unwrap(); - assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); - - s.edit(|doc, _| { - let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); - m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); - Ok(()) - }) - .unwrap(); - s.execute("master.placeholders", &json!({"title": true})).unwrap(); - let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); - assert_eq!(title.name, "Title"); - let box_ = title.xfrm.unwrap(); - assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); - assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); - } -} - -#[cfg(test)] -mod header_footer_date_tests { - use deckcraft_model::PhType; - use deckcraft_model::text::RunKind; - use serde_json::json; - - use crate::Session; - - /// The date field of the Date placeholders of every slide: (saved text, field type). - fn dates(s: &Session) -> Vec<(String, Option)> { - let d = &s.doc().unwrap().doc; - d.slides - .iter() - .flat_map(|sl| sl.shapes.iter()) - .filter(|sh| sh.ph_type() == Some(PhType::Date)) - .flat_map(|sh| sh.text.iter()) - .flat_map(|t| t.paragraphs.iter()) - .flat_map(|p| p.runs.iter()) - .map(|r| { - let kind = match &r.kind { - RunKind::Field { field } => Some(field.clone()), - _ => None, - }; - (r.text.clone(), kind) - }) - .collect() - } - - fn sample(kind: &str) -> String { - let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); - deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") - } - - /// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field. - /// The clock is read before and after the command, so a second ticking over between the - /// command and the read cannot fail the comparison. Returns the run text. - fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String { - let before = sample(kind); - s.execute("design.headerFooter", ¶ms).unwrap(); - let after = sample(kind); - let got = dates(s); - assert_eq!(got.len(), 1, "{got:?}"); - assert_eq!(got[0].1.as_deref(), Some(kind)); - assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}"); - got[0].0.clone() - } - - /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` - /// existed for this and was never read: the placeholder was always `datetime1`, and a - /// `dateFormat` parameter was accepted and silently dropped. - #[test] - fn the_automatic_date_takes_the_format_it_is_given() { - let mut s = Session::with_new(); - - // Default: the short date, as before. - run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1"); - - // A format: the field type changes and the saved text is that format's. - run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5"); - assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); - - // A time format works the same way, and these two differ, so the format is really read. - let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); - assert_ne!(time, sample("datetime5")); - - // Asking again changes nothing (and does not rewrite the run). - let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); - assert_eq!(again, time); - - // A fixed date still wins: plain text, no field. - s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); - assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); - - // Back to automatic: the remembered format comes back, not datetime1. - run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11"); - - // An unknown format is refused rather than ignored, and changes nothing. - for bad in ["datetime0", "datetime14", "nonsense", ""] { - assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); - } - assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); - let got = dates(&s); - assert_eq!(got.len(), 1, "{got:?}"); - assert_eq!(got[0].1.as_deref(), Some("datetime11")); - } -} +mod browse_theme_tests; diff --git a/crates/engine/src/cmd/design/browse_theme_tests.rs b/crates/engine/src/cmd/design/browse_theme_tests.rs new file mode 100644 index 000000000..46d8718a1 --- /dev/null +++ b/crates/engine/src/cmd/design/browse_theme_tests.rs @@ -0,0 +1,291 @@ +use std::sync::Arc; + +use deckcraft_model::text::RunKind; +use deckcraft_model::{Background, Fill, PhType}; +use serde_json::json; + +use crate::{Session, Target}; + +#[test] +fn applies_the_theme_of_another_deck_and_undoes() { + let mut other = Session::with_new(); + other.execute("design.fonts", &json!({"major": "Lato", "minor": "Source Sans 3"})).unwrap(); + other.execute("design.colors", &json!({"colors": {"accent1": "#123456"}})).unwrap(); + let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let before = s.doc().unwrap().doc.masters[0].theme.clone(); + s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); + let t = &s.doc().unwrap().doc.masters[0].theme; + assert_eq!(t.fonts.major.latin, "Lato"); + assert_eq!(t.fonts.minor.latin, "Source Sans 3"); + assert_eq!(t.colors.get(deckcraft_color::SchemeSlot::Accent1).hex().to_lowercase(), "123456"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme, before); + assert!(s.execute("design.browseTheme", &json!({"data": "AAAA"})).is_err()); + assert!(s.execute("design.browseTheme", &json!({})).is_err()); +} + +/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides +/// can only find them by kind) and its content layout's title moved. +fn brand_template() -> (String, Vec, deckcraft_model::Xfrm) { + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255])); + let mut logo = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap(); + let mut t = Session::with_new(); + t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap(); + let title = t + .edit(|doc, _| { + let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap(); + let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap()); + let m = Arc::make_mut(doc.masters.get_mut(0).unwrap()); + m.shapes.push(deckcraft_model::Shape { + id, + name: "Logo".into(), + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } }, + xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }), + ..Default::default() + }); + m.background = Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) }); + for l in &mut m.layouts { + l.name = format!("Brand {}", l.name); + } + let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap(); + let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() }; + title.xfrm = Some(x); + Ok(x) + }) + .unwrap(); + (crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title) +} + +#[test] +fn a_template_brings_its_master_and_layouts_and_slides_follow() { + let (data, logo, title) = brand_template(); + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let n = undo_len(&s); + let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap(); + assert_eq!(r["slides"], json!(2), "{r}"); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + + let doc = &s.doc().unwrap().doc; + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert_eq!(doc.masters.len(), 1, "the old master is replaced"); + let m = &doc.masters[0]; + assert_ne!(m.id, before.masters[0].id); + assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand "))); + assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } }))); + let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along"); + let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") }; + assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo)); + // The 4:3 master stretches to this deck's 16:9. + let k = doc.slide_size.width / 720.0; + let px = pic.xfrm.unwrap(); + assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}"); + + // Each slide moved to the brand layout of its kind, and kept its text. + for (old, new) in before.slides.iter().zip(&doc.slides) { + let (_, was) = before.layout(old.layout).unwrap(); + let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master"); + assert_eq!(owner.id, m.id); + assert_eq!(now.kind, was.kind); + } + let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap(); + assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one"))); + let (_, lay) = doc.layout(agenda.layout).unwrap(); + let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap(); + assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}"); + let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert!(st.xfrm.is_none(), "the slide title follows the new layout"); + + s.execute("edit.undo", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(doc.masters, before.masters); + assert_eq!(doc.slides, before.slides); +} + +#[test] +fn a_preserved_master_stays_beside_the_template() { + let (data, _, _) = brand_template(); + let mut s = Session::with_new(); + s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); + s.execute("master.preserve", &json!({"preserve": true})).unwrap(); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + s.execute("master.preserve", &json!({"preserve": true})).unwrap(); + let old = s.doc().unwrap().doc.masters[0].id; + s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap(); + s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.masters.len(), 2); + assert_eq!(st.doc.masters[1].id, old); + assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master"); + let new = &st.doc.masters[0]; + assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some())); +} + +fn undo_len(s: &crate::Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn effect_scheme_replaces_format_and_keeps_colours_and_fonts() { + let mut s = crate::Session::with_new(); + let listed = s.execute("design.themes", &json!({})).unwrap(); + let names: Vec = listed["effectSchemes"].as_array().unwrap().iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + assert_eq!(names, ["DeckCraft", "Soft", "Crisp", "Lift", "Glow", "Plain"]); + + let n = undo_len(&s); + s.execute("design.effects", &json!({"name": "DeckCraft"})).unwrap(); + assert_eq!(undo_len(&s), n, "the scheme already on the theme is not an undo step"); + let err = s.execute("design.effects", &json!({"name": "Office"})).unwrap_err().to_string(); + assert!(err.contains("no effect scheme"), "{err}"); + assert!(s.execute("design.effects", &json!({})).is_err()); + assert_eq!(undo_len(&s), n); + + s.execute("design.theme", &json!({"name": "Harbor"})).unwrap(); + let before = s.doc().unwrap().doc.masters[0].theme.clone(); + s.execute("design.effects", &json!({"name": "soft"})).unwrap(); + let theme = s.doc().unwrap().doc.masters[0].theme.clone(); + assert_eq!(theme.name, "Harbor"); + assert_eq!(theme.colors, before.colors); + assert_eq!(theme.fonts, before.fonts); + assert_eq!(theme.format.name, "Soft"); + assert!(theme.format.effects[0].outer_shadow.is_some()); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Effects"); + + let n = undo_len(&s); + s.execute("design.effects", &json!({"name": "Soft"})).unwrap(); + assert_eq!(undo_len(&s), n, "the same scheme is not an undo step"); + + s.execute("design.effects", &json!({"name": "Glow"})).unwrap(); + let glow = s.doc().unwrap().doc.masters[0].theme.format.effects[2].glow.clone(); + assert!(glow.is_some_and(|g| (g.radius - 12.0).abs() < 1e-6)); + s.execute("design.effects", &json!({"name": "Plain"})).unwrap(); + let plain = s.doc().unwrap().doc.masters[0].theme.format.clone(); + assert!(plain.effects.iter().all(|e| e.outer_shadow.is_none() && e.glow.is_none())); + assert_eq!(plain.lines[2].width, Some(0.75)); +} + +fn shapes(s: &Session) -> Vec { + s.doc().unwrap().shapes().to_vec() +} + +fn has_title(s: &Session) -> bool { + shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)) +} + +fn has_footers(s: &Session) -> bool { + shapes(s).iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)) +} + +#[test] +fn preserve_and_placeholders_edit_the_open_master_or_layout() { + for id in ["master.preserve", "master.placeholders"] { + assert!(crate::command_specs().iter().any(|c| c.id == id), "{id}"); + } + + let mut s = Session::with_new(); + let blank = s.doc().unwrap().doc.masters[0].layouts.iter().position(|l| l.name == "Blank").unwrap(); + + s.execute("view.slideMaster", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: 0 }); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Master { master: 0 }); + assert!(!s.doc().unwrap().doc.masters[0].preserve); + + let n = s.doc().unwrap().history.undo.len(); + s.execute("master.preserve", &json!({"preserve": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "the same value is not an undo step"); + s.execute("master.preserve", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.masters[0].preserve); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().doc.masters[0].preserve); + + s.execute("master.placeholders", &json!({"footers": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); + assert!(has_title(&s)); + assert!(!has_footers(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(has_footers(&s)); + + master_view_closed_rejects(&mut s, blank); +} + +fn master_view_closed_rejects(s: &mut Session, blank: usize) { + s.execute("view.closeMaster", &json!({})).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let err = s.execute("master.preserve", &json!({})).unwrap_err(); + assert!(err.to_string().contains("open Slide Master view"), "{err}"); + let err = s.execute("master.placeholders", &json!({"title": true})).unwrap_err(); + assert!(err.to_string().contains("open Slide Master view"), "{err}"); + let err = s.execute("master.placeholders", &json!({})).unwrap_err(); + assert!(err.to_string().contains("give title or footers"), "{err}"); + assert_eq!(s.doc().unwrap().history.undo.len(), n); + + layout_placeholders_edit_one_undo_step(s, blank); +} + +fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) { + s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap(); + assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank }); + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + assert!(!has_title(s)); + assert!(has_footers(s)); + + let n = s.doc().unwrap().history.undo.len(); + s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n, "already in that state"); + + s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders"); + assert!(has_title(s)); + assert!(!has_footers(s)); + let title = shapes(s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap(); + assert_eq!(title.name, "Title 1"); + assert_eq!(title.ph.unwrap().idx, 0); + assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty())); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!has_title(s)); + assert!(has_footers(s)); + + s.execute("master.placeholders", &json!({"footers": false})).unwrap(); + s.execute("master.placeholders", &json!({"footers": true})).unwrap(); + let restored = shapes(s); + assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date))); + assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer))); + let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap(); + let run = &num.text.as_ref().unwrap().paragraphs[0].runs[0]; + assert_eq!(run.text, "‹#›"); + assert!(matches!(&run.kind, RunKind::Field { field } if field == "slidenum")); + + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + s.execute("master.preserve", &json!({"preserve": false})).unwrap(); + assert!(!s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Preserve"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve); + + missing_title_placeholder_is_recreated(s); +} + +fn missing_title_placeholder_is_recreated(s: &mut Session) { + s.edit(|doc, _| { + let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap()); + m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + Ok(()) + }) + .unwrap(); + s.execute("master.placeholders", &json!({"title": true})).unwrap(); + let title = shapes(s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap(); + assert_eq!(title.name, "Title"); + let box_ = title.xfrm.unwrap(); + assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01); + assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01); +} diff --git a/crates/engine/src/cmd/design/colors.rs b/crates/engine/src/cmd/design/colors.rs new file mode 100644 index 000000000..ec573d88b --- /dev/null +++ b/crates/engine/src/cmd/design/colors.rs @@ -0,0 +1,257 @@ +use super::{ + Arc, Result, Session, Value, bad, builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, json, master_index, ok, str_param, +}; + +pub(super) fn colors(s: &mut Session, p: &Value) -> Result { + let name_v = p.get("name"); + let colors_v = p.get("colors"); + if name_v.is_none() && colors_v.is_none() { + let sc = current_colors(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeColors".into(), params: scheme_dialog_params(&sc) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.colors", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.colors", "`name` must be a string")), + }; + let slots = parse_color_slots(colors_v)?; + let current = current_colors(s)?; + let next = next_scheme(¤t, given.as_deref(), slots.as_deref())?; + if next == current { + return ok(); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.colors", "no master"))?); + m.theme.colors = next; + Ok(()) + })?; + ok() +} + +pub(super) fn variant(s: &mut Session, p: &Value) -> Result { + let index = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("design.variant", "missing `index` (0..3)"))? as usize; + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.variant", "no master"))?); + let v = deckcraft_model::theme::theme_variants(&m.theme); + m.theme.colors = + v.get(index).cloned().ok_or_else(|| bad("design.variant", format!("no variant {index}; 0..{}", v.len().saturating_sub(1))))?; + Ok(()) + })?; + Ok(json!({"variant": index})) +} + +pub(super) fn variants(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + let th = st.doc.masters.get(mi).map(|m| m.theme.clone()).unwrap_or_default(); + let v = deckcraft_model::theme::theme_variants(&th); + let current = v.iter().position(|c| c.colors == th.colors.colors); + Ok(json!({ + "theme": th.name, + "current": current, + "variants": v.iter().map(|c| json!({"name": c.name, "colors": c.colors.iter().map(|x| format!("#{}", x.hex())).collect::>()})).collect::>(), + })) +} + +fn current_colors(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.colors.clone()).ok_or_else(|| bad("design.colors", "no master")) +} + +fn scheme_dialog_params(sc: &deckcraft_color::ColorScheme) -> Value { + let mut colors = serde_json::Map::new(); + for slot in deckcraft_color::SchemeSlot::THEME { + colors.insert(slot.xml_name().to_string(), Value::String(format!("#{}", sc.get(slot).hex()))); + } + json!({"name": sc.name, "colors": colors}) +} + +/// `None` when the key is absent. Present values must be the twelve stored slots; aliases such +/// as `bg1` are rejected because `ColorScheme::set` ignores them. +fn parse_color_slots(colors_v: Option<&Value>) -> Result>> { + let Some(v) = colors_v else { return Ok(None) }; + let Some(map) = v.as_object() else { + return Err(bad("design.colors", "`colors` must be an object")); + }; + if map.is_empty() { + return Err(bad("design.colors", "`colors` is empty")); + } + let mut slots = Vec::with_capacity(map.len()); + for (k, v) in map { + let Some(slot) = deckcraft_color::SchemeSlot::from_xml(k) else { + return Err(bad("design.colors", format!("unknown colour `{k}`"))); + }; + if !deckcraft_color::SchemeSlot::THEME.contains(&slot) { + return Err(bad("design.colors", format!("`{k}` is not a theme colour"))); + } + let Some(rgb) = v.as_str().and_then(deckcraft_color::Rgba::from_hex) else { + return Err(bad("design.colors", format!("`{k}` needs a colour like #RRGGBB"))); + }; + slots.push((slot, rgb)); + } + Ok(Some(slots)) +} + +/// A builtin `name` is applied first, then `colors` overlays those slots. The stored scheme name +/// is the given name, or `Custom` when only colours are sent. An unknown name without colours is an error. +fn next_scheme( + current: &deckcraft_color::ColorScheme, + given: Option<&str>, + slots: Option<&[(deckcraft_color::SchemeSlot, deckcraft_color::Rgba)]>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + if let Some(builtin) = builtin_color_schemes().into_iter().find(|c| c.name.eq_ignore_ascii_case(name)) { + next = builtin; + } else if slots.is_none() { + return Err(bad("design.colors", format!("no colour scheme `{name}`"))); + } + next.name = name.to_string(); + } + if let Some(changes) = slots { + for (slot, rgb) in changes { + next.set(*slot, *rgb); + } + if given.is_none() { + next.name = "Custom".into(); + } + } + Ok(next) +} + +pub(super) fn fonts(s: &mut Session, p: &Value) -> Result { + let name_v = p.get("name"); + let major_v = p.get("major"); + let minor_v = p.get("minor"); + if name_v.is_none() && major_v.is_none() && minor_v.is_none() { + let scheme = current_fonts(s)?; + s.ui_requests.push(crate::UiRequest::Dialog { id: "themeFonts".into(), params: font_dialog_params(&scheme) }); + return ok(); + } + let given = match name_v { + None => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() { + return Err(bad("design.fonts", "name is empty")); + } + Some(n.to_string()) + } + Some(_) => return Err(bad("design.fonts", "`name` must be a string")), + }; + let major = face_param("major", major_v)?; + let minor = face_param("minor", minor_v)?; + let current = current_fonts(s)?; + let next = next_font_scheme(¤t, given.as_deref(), major.as_deref(), minor.as_deref())?; + if next == current { + return ok(); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.fonts", "no master"))?).theme.fonts = next; + Ok(()) + })?; + ok() +} + +fn current_fonts(s: &Session) -> Result { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).map(|m| m.theme.fonts.clone()).ok_or_else(|| bad("design.fonts", "no master")) +} + +fn font_dialog_params(scheme: &deckcraft_model::theme::FontScheme) -> Value { + json!({"name": scheme.name, "major": scheme.major.latin, "minor": scheme.minor.latin}) +} + +/// `None` when the key is absent. A present face must be a non-blank string. +fn face_param(key: &str, v: Option<&Value>) -> Result> { + match v { + None => Ok(None), + Some(Value::String(s)) => { + let s = s.trim(); + if s.is_empty() { Err(bad("design.fonts", format!("`{key}` is empty"))) } else { Ok(Some(s.to_string())) } + } + Some(_) => Err(bad("design.fonts", format!("`{key}` must be a string"))), + } +} + +/// A builtin `name` is applied first, then `major` / `minor` replace only the latin faces (East +/// Asian, complex-script and per-script slots stay). The stored name is the given string when +/// faces are overlaid or the name is not a builtin; a name-only builtin keeps its own name. +/// Faces without a name are a custom scheme: an omitted `minor` copies `major`. +fn next_font_scheme( + current: &deckcraft_model::theme::FontScheme, + given: Option<&str>, + major: Option<&str>, + minor: Option<&str>, +) -> Result { + let mut next = current.clone(); + if let Some(name) = given { + let replaced = if let Some(builtin) = builtin_font_schemes().into_iter().find(|f| f.name.eq_ignore_ascii_case(name)) { + next = builtin; + true + } else { + false + }; + if !replaced && major.is_none() && minor.is_none() { + return Err(bad("design.fonts", format!("no font scheme `{name}`"))); + } + if major.is_some() || minor.is_some() || !replaced { + next.name = name.to_string(); + } + } + if given.is_none() { + if let Some(face) = major { + next.major.latin = face.to_string(); + if minor.is_none() { + next.minor.latin = face.to_string(); + } + } + if let Some(face) = minor { + next.minor.latin = face.to_string(); + } + next.name = "Custom".into(); + } else { + if let Some(face) = major { + next.major.latin = face.to_string(); + } + if let Some(face) = minor { + next.minor.latin = face.to_string(); + } + } + Ok(next) +} + +pub(super) fn effects(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("design.effects", "missing `name`"))?; + let scheme = builtin_format_schemes() + .into_iter() + .find(|f| f.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("design.effects", format!("no effect scheme `{name}`")))?; + let stored = scheme.name.clone(); + let same = { + let st = s.doc()?; + let mi = master_index(&st.doc, &st.selection); + st.doc.masters.get(mi).is_some_and(|m| m.theme.format == scheme) + }; + if same { + return Ok(json!({"name": stored})); + } + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("design.effects", "no master"))?).theme.format = scheme; + Ok(()) + })?; + Ok(json!({"name": stored})) +} diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs new file mode 100644 index 000000000..5dfe04964 --- /dev/null +++ b/crates/engine/src/cmd/design/master_view.rs @@ -0,0 +1,501 @@ +use super::{ + Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json, + master_index, ok, opt_usize_param, rect_param, str_param, usize_param, +}; + +pub(super) fn master_view(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection)); + // A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects + // that layout. With neither key, follow the current slide's layout. + let layout = if p.get("master").is_some() && p.get("layout").is_none() { + None + } else { + usize_param(p, "layout") + .or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout)))) + }; + let target = match layout { + Some(l) => Target::Layout { master: mi, layout: l }, + None => Target::Master { master: mi }, + }; + s.select(|_, sel| { + sel.target = target; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(serde_json::to_value(target).unwrap_or_default()) +} + +pub(super) fn in_handout(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::Handout) { Ok(()) } else { Err("open View ▸ Handout Master first".into()) } +} + +/// The four handout-master placeholders PowerPoint offers, in a page of `size`. +pub fn handout_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + let (name, idx, x, y) = match kind { + PhType::Header => ("Header Placeholder", 0, 0.0, 0.0), + PhType::Date => ("Date Placeholder", 1, w * 0.57, 0.0), + PhType::Footer => ("Footer Placeholder", 2, 0.0, h * 0.95), + _ => ("Slide Number Placeholder", 3, w * 0.57, h * 0.95), + }; + let mut text = TextBody::default(); + if let Some(p) = text.paragraphs.first_mut() + && matches!(kind, PhType::Date | PhType::SlideNum) + { + p.props.align = Some(deckcraft_model::text::Align::Right); + } + Shape { + id, + name: format!("{name} {}", idx + 1), + xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w * 0.43, h * 0.05)), + ph: Some(deckcraft_model::Placeholder { kind, idx, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +const HANDOUT_KINDS: [(&str, PhType); 4] = + [("header", PhType::Header), ("date", PhType::Date), ("footer", PhType::Footer), ("pageNumber", PhType::SlideNum)]; + +pub(super) fn handout_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.handout_master.is_none(); + if created { + s.edit(|doc, _| { + let mut m = deckcraft_model::Master { + name: "Handout Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }; + let size = doc.notes_size; + for (_, k) in HANDOUT_KINDS { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.handoutMaster", NO_ID_SPACE))?); + m.shapes.push(handout_placeholder(k, size, id)); + } + doc.handout_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::Handout; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +pub(super) fn handout_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let mut ids = vec![]; + for (key, _) in HANDOUT_KINDS { + let id = match p.get(key).and_then(Value::as_bool) { + Some(_) => Some(ShapeId(doc.alloc_id().ok_or_else(|| bad("handout.placeholders", NO_ID_SPACE))?)), + None => None, + }; + ids.push(id); + } + let m = Arc::make_mut(doc.handout_master.as_mut().ok_or_else(|| bad("handout.placeholders", "no handout master"))?); + for ((key, kind), id) in HANDOUT_KINDS.into_iter().zip(ids) { + let (Some(on), Some(id)) = (p.get(key).and_then(Value::as_bool), id) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(handout_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.handout_master.clone(); + let shown = |k: PhType| m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(k))); + Ok(json!({"header": shown(PhType::Header), "date": shown(PhType::Date), "footer": shown(PhType::Footer), "pageNumber": shown(PhType::SlideNum)})) +} + +pub(super) fn in_notes_master(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.target == Target::NotesMaster) { Ok(()) } else { Err("open View ▸ Notes Master first".into()) } +} + +const NOTES_KINDS: [(&str, PhType); 6] = [ + ("header", PhType::Header), + ("date", PhType::Date), + ("slideImage", PhType::SlideImage), + ("body", PhType::Body), + ("footer", PhType::Footer), + ("pageNumber", PhType::SlideNum), +]; + +/// A notes-master placeholder in a page of `size`, laid out as PowerPoint's default notes master. +pub fn notes_placeholder(kind: PhType, size: Size, id: ShapeId) -> Shape { + let (w, h) = (size.width, size.height); + match kind { + PhType::SlideImage => Shape { + id, + name: "Slide Image Placeholder 3".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.125, h * 0.125, w * 0.75, h * 0.375)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 2, ..Default::default() }), + fill: Some(Fill::None), + line: Some(deckcraft_model::Line::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK), 1.0)), + ..Default::default() + }, + PhType::Body => { + let mut text = TextBody::default(); + text.paragraphs = vec![deckcraft_model::text::Paragraph::new("Click to edit Master text styles")]; + Shape { + id, + name: "Notes Placeholder 4".into(), + xfrm: Some(deckcraft_geom::Xfrm::new(w * 0.1, h * 0.53, w * 0.8, h * 0.36)), + ph: Some(deckcraft_model::Placeholder { kind, idx: 3, size: Some("quarter".into()), ..Default::default() }), + text: Some(text), + ..Default::default() + } + } + _ => { + let mut s = handout_placeholder(kind, size, id); + if let Some(ph) = s.ph.as_mut() { + ph.idx = match kind { + PhType::Header => 0, + PhType::Date => 1, + PhType::Footer => 4, + _ => 5, + }; + } + s + } + } +} + +pub(super) fn notes_view(s: &mut Session, _p: &Value) -> Result { + let created = s.doc()?.doc.notes_master.as_ref().is_none_or(|m| m.shapes.is_empty()); + if created { + s.edit(|doc, _| { + let mut m = doc.notes_master.as_deref().cloned().unwrap_or_else(|| deckcraft_model::Master { + name: "Notes Master".into(), + theme: doc.masters.first().map(|m| m.theme.clone()).unwrap_or_default(), + color_map: doc.masters.first().map(|m| m.color_map.clone()).unwrap_or_default(), + ..Default::default() + }); + let size = doc.notes_size; + for (_, k) in NOTES_KINDS { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("view.notesMaster", NO_ID_SPACE))?); + m.shapes.push(notes_placeholder(k, size, id)); + } + doc.notes_master = Some(Arc::new(m)); + Ok(()) + })?; + } + s.select(|_, sel| { + sel.target = Target::NotesMaster; + sel.shapes.clear(); + sel.text = None; + })?; + Ok(json!({"created": created})) +} + +pub(super) fn notes_placeholders(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, _| { + let size = doc.notes_size; + let ids: Vec = + NOTES_KINDS.iter().map(|_| doc.alloc_id().map(ShapeId).ok_or_else(|| bad("notes.placeholders", NO_ID_SPACE))).collect::>()?; + let m = Arc::make_mut(doc.notes_master.as_mut().ok_or_else(|| bad("notes.placeholders", "no notes master"))?); + for ((key, kind), id) in NOTES_KINDS.into_iter().zip(ids) { + let Some(on) = p.get(key).and_then(Value::as_bool) else { continue }; + let has = m.shapes.iter().any(|x| x.ph_type() == Some(kind)); + if on && !has { + m.shapes.push(notes_placeholder(kind, size, id)); + } else if !on { + m.shapes.retain(|x| x.ph_type() != Some(kind)); + } + } + Ok(()) + })?; + let m = s.doc()?.doc.notes_master.clone(); + let mut out = serde_json::Map::new(); + for (key, kind) in NOTES_KINDS { + out.insert(key.into(), json!(m.as_ref().is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(kind))))); + } + Ok(Value::Object(out)) +} + +pub(super) fn close_master(s: &mut Session, _p: &Value) -> Result { + s.select(|_, sel| { + sel.target = Target::Slides; + sel.shapes.clear(); + sel.text = None; + })?; + ok() +} + +pub(super) fn insert_layout(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("Custom Layout").to_string(); + s.edit(|doc, sel| { + let mi = master_index(doc, sel); + let id = deckcraft_model::LayoutId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); + let tid = ShapeId(doc.alloc_id().ok_or_else(|| bad("master.insertLayout", NO_ID_SPACE))?); + let size = doc.slide_size; + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.insertLayout", "no master"))?); + let title = m.shapes.iter().find(|x| x.ph_type() == Some(PhType::Title)).cloned().map(|mut t| { + t.id = tid; + t.text = Some(TextBody::default()); + t + }); + let _ = size; + let at = match sel.target { + Target::Layout { layout, .. } => layout + 1, + _ => m.layouts.len(), + }; + m.layouts.insert( + at.min(m.layouts.len()), + deckcraft_model::Layout { + id, + name, + kind: deckcraft_model::LayoutType::Custom, + shapes: title.into_iter().collect(), + background: None, + show_master_shapes: true, + preserve: true, + color_map: None, + raw_ext: None, + }, + ); + sel.target = Target::Layout { master: mi, layout: at.min(m.layouts.len().saturating_sub(1)) }; + Ok(json!({"id": id})) + }) +} + +fn layout_target(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let st = s.doc()?; + let (m, l) = match st.selection.target { + Target::Layout { master, layout } => (master, layout), + _ => (master_index(&st.doc, &st.selection), 0), + }; + Ok((opt_usize_param(p, "master", cmd)?.unwrap_or(m), opt_usize_param(p, "layout", cmd)?.unwrap_or(l))) +} + +pub(super) fn rename_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p, "master.renameLayout")?; + let name = str_param(p, "name").ok_or_else(|| bad("master.renameLayout", "missing `name`"))?.to_string(); + s.edit(|doc, _| { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.renameLayout", "no such layout"))?; + l.name = name; + Ok(()) + })?; + ok() +} + +pub(super) fn delete_layout(s: &mut Session, p: &Value) -> Result { + let (mi, li) = layout_target(s, p, "master.deleteLayout")?; + s.edit(|doc, sel| { + let id = doc.masters.get(mi).and_then(|m| m.layouts.get(li)).map(|l| l.id).ok_or_else(|| bad("master.deleteLayout", "no such layout"))?; + if doc.slides.iter().any(|x| x.layout == id) { + return Err(bad("master.deleteLayout", "slides use this layout")); + } + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.deleteLayout", "no master"))?); + if m.layouts.len() <= 1 { + return Err(bad("master.deleteLayout", "a master needs at least one layout")); + } + m.layouts.remove(li); + sel.target = Target::Layout { master: mi, layout: li.min(m.layouts.len().saturating_sub(1)) }; + Ok(()) + })?; + ok() +} + +pub(super) fn insert_placeholder(s: &mut Session, p: &Value) -> Result { + let kind = match str_param(p, "kind").unwrap_or("content") { + "text" => PhType::Body, + "picture" => PhType::Picture, + "chart" => PhType::Chart, + "table" => PhType::Table, + "media" => PhType::Media, + _ => PhType::Obj, + }; + if !matches!(s.doc()?.selection.target, Target::Layout { .. }) { + return Err(bad("master.insertPlaceholder", "select a layout in Slide Master view")); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(deckcraft_geom::Xfrm::new(size.width * 0.25, size.height * 0.3, size.width * 0.5, size.height * 0.4)); + let idx = s.doc()?.shapes().iter().filter_map(|x| x.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0) + 1; + let shape = Shape { + xfrm: Some(rect), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(TextBody::default()), + ..Default::default() + }; + let id = crate::cmd::insert::add_shape(s, "master.insertPlaceholder", shape, true)?; + Ok(json!({"id": id})) +} + +pub(super) fn preserve(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let (on_master, mi, li, current) = match st.selection.target { + Target::Master { master } => { + let on = st.doc.masters.get(master).map(|m| m.preserve).ok_or_else(|| bad("master.preserve", "no such master"))?; + (true, master, 0, on) + } + Target::Layout { master, layout } => { + let on = st + .doc + .masters + .get(master) + .and_then(|m| m.layouts.get(layout)) + .map(|l| l.preserve) + .ok_or_else(|| bad("master.preserve", "no such layout"))?; + (false, master, layout, on) + } + Target::Slides | Target::Handout | Target::NotesMaster => return Err(bad("master.preserve", "open Slide Master view")), + }; + let next = bool_param(p, "preserve").unwrap_or(!current); + if next == current { + return Ok(json!({"preserve": next})); + } + s.edit(|doc, _| { + if on_master { + let m = Arc::make_mut(doc.masters.get_mut(mi).ok_or_else(|| bad("master.preserve", "no such master"))?); + m.preserve = next; + } else { + let l = + doc.masters.get_mut(mi).and_then(|m| Arc::make_mut(m).layouts.get_mut(li)).ok_or_else(|| bad("master.preserve", "no such layout"))?; + l.preserve = next; + } + Ok(()) + })?; + Ok(json!({"preserve": next})) +} + +pub(super) fn placeholders(s: &mut Session, p: &Value) -> Result { + let title = bool_param(p, "title"); + let footers = bool_param(p, "footers"); + if title.is_none() && footers.is_none() { + return Err(bad("master.placeholders", "give title or footers")); + } + let st = s.doc()?; + if matches!(st.selection.target, Target::Slides) { + return Err(bad("master.placeholders", "open Slide Master view")); + } + let has_title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let has_footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + let title_changes = title.is_some_and(|want| want != has_title); + let footer_changes = footers.is_some_and(|want| want != has_footers); + if !title_changes && !footer_changes { + return Ok(json!({"title": has_title, "footers": has_footers})); + } + let mut add = Vec::new(); + if title == Some(true) && !has_title { + add.push(PhType::Title); + } + if footers == Some(true) && !has_footers { + add.extend([PhType::Date, PhType::Footer, PhType::SlideNum]); + } + let size = st.doc.slide_size; + s.edit(|doc, sel| { + let mut ids = Vec::with_capacity(add.len()); + for _ in 0..add.len() { + ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("master.placeholders", NO_ID_SPACE))?)); + } + let prototypes = match sel.target { + Target::Master { master } | Target::Layout { master, .. } => doc.masters.get(master).map(|m| m.shapes.clone()).unwrap_or_default(), + Target::Slides => Vec::new(), + Target::Handout | Target::NotesMaster => return Err(bad("master.placeholders", "open Slide Master view")), + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("master.placeholders", "open Slide Master view"))?; + if title == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title)); + } + if footers == Some(false) { + shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_footer_kind)); + } + for (kind, id) in add.into_iter().zip(ids) { + let idx = free_ph_idx(shapes, preferred_ph_idx(kind)); + let proto = prototypes.iter().find(|sh| match kind { + PhType::Title => sh.ph_type().is_some_and(PhType::is_title), + other => sh.ph_type() == Some(other), + }); + let shape = match proto { + Some(src) => retarget_placeholder(src.clone(), id, idx), + None => synthesized_placeholder(kind, id, idx, size), + }; + shapes.push(shape); + } + Ok(()) + })?; + let st = s.doc()?; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_title)); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(PhType::is_footer_kind)); + Ok(json!({"title": title, "footers": footers})) +} + +fn preferred_ph_idx(kind: PhType) -> u32 { + match kind { + PhType::Title | PhType::CtrTitle => 0, + PhType::Date => 10, + PhType::Footer => 11, + PhType::SlideNum => 12, + _ => 1, + } +} + +fn free_ph_idx(shapes: &[Shape], preferred: u32) -> u32 { + let taken = |idx: u32| shapes.iter().any(|sh| sh.ph.as_ref().is_some_and(|p| p.idx == idx)); + if !taken(preferred) { preferred } else { shapes.iter().filter_map(|sh| sh.ph.as_ref().map(|p| p.idx)).max().unwrap_or(0).saturating_add(1) } +} + +/// A copy of a master placeholder, with a new id and index. Prompt text is cleared; a slide +/// number keeps its `‹#›` field. +fn retarget_placeholder(mut sh: Shape, id: ShapeId, idx: u32) -> Shape { + sh.id = id; + if let Some(ph) = sh.ph.as_mut() { + ph.idx = idx; + } + let slide_num = sh.ph_type() == Some(PhType::SlideNum); + if slide_num { + let paras = vec![slide_number_paragraph()]; + if let Some(text) = sh.text.as_mut() { + text.paragraphs = paras; + } else { + sh.text = Some(TextBody { paragraphs: paras, ..Default::default() }); + } + } else if let Some(text) = sh.text.as_mut() { + text.paragraphs.clear(); + } + sh +} + +fn synthesized_placeholder(kind: PhType, id: ShapeId, idx: u32, size: Size) -> Shape { + let (name, text) = match kind { + PhType::Date => ("Date Placeholder", TextBody::default()), + PhType::Footer => ("Footer Placeholder", TextBody::default()), + PhType::SlideNum => ("Slide Number Placeholder", TextBody { paragraphs: vec![slide_number_paragraph()], ..Default::default() }), + _ => ("Title", TextBody::default()), + }; + Shape { + id, + name: name.into(), + xfrm: Some(placeholder_rect(kind, size)), + ph: Some(deckcraft_model::Placeholder { kind, idx, ..Default::default() }), + text: Some(text), + ..Default::default() + } +} + +/// Standard placeholder boxes at 960×540, scaled to the slide size. +fn placeholder_rect(kind: PhType, size: Size) -> deckcraft_geom::Xfrm { + let sx = size.width / 960.0; + let sy = size.height / 540.0; + let (x, y, w, h) = match kind { + PhType::Date => (60.0, 500.0, 216.0, 28.0), + PhType::Footer => (318.0, 500.0, 324.0, 28.0), + PhType::SlideNum => (684.0, 500.0, 216.0, 28.0), + _ => (60.0, 29.0, 840.0, 104.0), + }; + deckcraft_geom::Xfrm::new(x * sx, y * sy, w * sx, h * sy) +} + +fn slide_number_paragraph() -> deckcraft_model::Paragraph { + deckcraft_model::Paragraph { + runs: vec![Run { text: "‹#›".into(), props: Default::default(), kind: RunKind::Field { field: "slidenum".into() } }], + ..Default::default() + } +} diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs new file mode 100644 index 000000000..0de78e7e3 --- /dev/null +++ b/crates/engine/src/cmd/design/slide_background.rs @@ -0,0 +1,301 @@ +use super::{ + Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value, + f64_param, ok, usize_param, with_param, +}; + +pub(super) fn background(s: &mut Session, p: &Value) -> Result { + if bool_or(p, "copy", false) { + return copy_slide_background(s); + } + let props = p.get("pictureProps").filter(|v| v.is_object()).cloned(); + let transparency = f64_param(p, "transparency"); + let reset = bool_or(p, "reset", false); + let color = color_param(p, "color"); + let style = usize_param(p, "style"); + let op = if reset { + BgOp::Set(None) + } else if let Some(mut c) = color { + if let Some(t) = transparency { + set_solid_transparency(&mut c, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) })) + } else if let Some(i) = style { + // Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2). + let slots = + [deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1]; + let i = i.clamp(1, 12) - 1; + BgOp::Set(Some(Background::Ref { + idx: 1001 + (i / 4) as u32, + color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)), + })) + } else if let Some(g) = p.get("gradient") { + let mut g = crate::cmd::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?; + g.rotate_with_shape = false; + BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) })) + } else if let Some(pt) = p.get("pattern") { + BgOp::Set(Some(Background::Fill { + fill: Fill::Pattern(deckcraft_model::style::PatternFill { + preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(), + fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)), + bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)), + }), + })) + } else if p.get("picture").is_some() { + let (name, bytes) = + crate::cmd::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?; + let ct = crate::cmd::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("design.background", NO_ID_SPACE)))?; + let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() }; + if let Some(pp) = &props { + apply_picture_props(&mut pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(&mut pf, t); + } + BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) })) + } else if props.is_some() || transparency.is_some() { + BgOp::Adjust + } else { + return Err(bad( + "design.background", + "give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`", + )); + }; + let all = bool_or(p, "all", false); + let index = usize_param(p, "index"); + if matches!(op, BgOp::Adjust) { + let next = { + let st = s.doc()?; + let current = background_of(&st.doc, &st.selection, index); + let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) })); + let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } })); + if props.is_some() && !picture { + return Err(bad("design.background", "picture options need a picture background")); + } + if transparency.is_some() && !picture && !solid { + return Err(bad("design.background", "transparency needs a solid or picture background")); + } + let mut next = current.clone(); + if let Some(bg) = next.as_mut() { + adjust_background(bg, props.as_ref(), transparency); + } + if next == current { None } else { Some(next) } + }; + let Some(next) = next else { return ok() }; + s.edit(|doc, sel| { + write_background(doc, sel, index, false, next); + Ok(()) + })?; + return ok(); + } + let BgOp::Set(bg) = op else { return ok() }; + let unchanged = { + let st = s.doc()?; + backgrounds_already(&st.doc, &st.selection, index, all, &bg) + }; + if unchanged { + return ok(); + } + s.edit(|doc, sel| { + write_background(doc, sel, index, all, bg); + Ok(()) + })?; + ok() +} + +enum BgOp { + Set(Option), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + match sel.target { + Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()), + Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()), + } +} + +fn used_master_indices(doc: &Presentation) -> BTreeSet { + let fallback = (!doc.masters.is_empty()).then_some(0); + doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect() +} + +fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option, all: bool, bg: &Option) -> bool { + match sel.target { + Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg), + Target::Slides => { + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg)); + if all && bg.is_some() { + let masters = used_master_indices(doc); + let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg)); + let layouts_match = !masters.is_empty() + && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none()))); + slides_match && masters_match && layouts_match + } else { + slides_match + } + } + } +} + +fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option, all: bool, bg: Option) { + match sel.target { + Target::Master { master } => { + if let Some(m) = doc.masters.get_mut(master) { + Arc::make_mut(m).background = bg; + } + } + Target::Layout { master, layout } => { + if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) { + l.background = bg; + } + } + t @ (Target::Handout | Target::NotesMaster) => { + if let Some(m) = crate::page_master_mut(doc, t) { + Arc::make_mut(m).background = bg; + } + } + Target::Slides => { + if all && bg.is_some() { + for idx in used_master_indices(doc) { + if let Some(m) = doc.masters.get_mut(idx) { + let m = Arc::make_mut(m); + m.background = bg.clone(); + for l in &mut m.layouts { + l.background = None; + } + } + } + } + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] }; + for i in list { + if let Some(sl) = doc.slides.get_mut(i) { + Arc::make_mut(sl).background = bg.clone(); + } + } + } + } +} + +fn copy_slide_background(s: &mut Session) -> Result { + let bg = { + let st = s.doc()?; + if !matches!(st.selection.target, Target::Slides) { + return Err(bad("design.background", "copy applies to slides")); + } + let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone()); + let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg); + let masters_match = if bg.is_some() { + let masters = used_master_indices(&st.doc); + !masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg)) + } else { + true + }; + if slides_match && masters_match { + return ok(); + } + bg + }; + s.edit(|doc, sel| { + write_background(doc, sel, None, true, bg); + Ok(()) + })?; + ok() +} + +fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option) { + match bg { + Background::Fill { fill: Fill::Picture(pf) } => { + if let Some(pp) = props { + apply_picture_props(pf, pp); + } + if let Some(t) = transparency { + set_picture_transparency(pf, t); + } + } + Background::Fill { fill: Fill::Solid { color } } => { + if let Some(t) = transparency { + set_solid_transparency(color, t); + } + } + _ => {} + } +} + +fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) { + let a = 1.0 - t.clamp(0.0, 1.0); + pf.alpha = (a < 1.0).then_some(a); +} + +fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) { + color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_))); + let t = t.clamp(0.0, 1.0); + if t > 0.0 { + color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32)); + } +} + +/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch). +fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) { + use deckcraft_model::style::PictureMode; + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + +pub(super) fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result { + 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() +} diff --git a/crates/engine/src/cmd/design/slide_setup.rs b/crates/engine/src/cmd/design/slide_setup.rs new file mode 100644 index 000000000..86d9317b7 --- /dev/null +++ b/crates/engine/src/cmd/design/slide_setup.rs @@ -0,0 +1,339 @@ +use super::{ + Arc, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, TextBody, Value, bad, bool_or, bool_param, defaults, f64_param, + json, ok, str_param, +}; + +pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) { + for sh in shapes { + if let Some(x) = sh.xfrm.as_mut() { + x.x = x.x * sx + ox; + x.y = x.y * sy + oy; + x.w *= sx; + x.h *= sy; + } + if let Some(t) = sh.text.as_mut() { + deckcraft_model::edit::format_all(t, &|r| { + if let Some(sz) = r.size.as_mut() { + *sz = (*sz * k).max(1.0); + } + }); + } + } +} + +pub(super) fn slide_size(s: &mut Session, p: &Value) -> Result { + let size = if let Some(pre) = str_param(p, "preset") { + let k = pre.to_ascii_lowercase(); + let found = defaults::SLIDE_SIZES.iter().find(|(l, _, _)| l.to_ascii_lowercase().starts_with(&k) || l.to_ascii_lowercase().contains(&k)); + let (_, w, h) = found.ok_or_else(|| bad("design.slideSize", format!("unknown preset `{pre}`")))?; + Size::new(*w, *h) + } else { + Size::new( + f64_param(p, "w").ok_or_else(|| bad("design.slideSize", "missing `w`"))?.clamp(72.0, 4032.0), + f64_param(p, "h").ok_or_else(|| bad("design.slideSize", "missing `h`"))?.clamp(72.0, 4032.0), + ) + }; + let size = match str_param(p, "orientation") { + Some("portrait") if size.width > size.height => Size::new(size.height, size.width), + Some("landscape") if size.height > size.width => Size::new(size.height, size.width), + Some("portrait" | "landscape") | None => size, + Some(o) => return Err(bad("design.slideSize", format!("unknown orientation `{o}`"))), + }; + let mode = str_param(p, "scale").unwrap_or("ensureFit").to_string(); + s.edit(|doc, _| { + let old = doc.slide_size; + let (sx, sy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + let (sx, sy, k, ox, oy) = match mode.as_str() { + "none" => (1.0, 1.0, 1.0, 0.0, 0.0), + "maximize" => { + let k = sx.max(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + _ => { + let k = sx.min(sy); + (k, k, k, (size.width - old.width * k) / 2.0, (size.height - old.height * k) / 2.0) + } + }; + // Masters and layouts stretch to the new size; slides scale by mode. + let (msx, msy) = (size.width / old.width.max(1.0), size.height / old.height.max(1.0)); + for m in &mut doc.masters { + let m = Arc::make_mut(m); + scale_shapes(&mut m.shapes, msx, msy, 1.0, 0.0, 0.0); + for l in &mut m.layouts { + scale_shapes(&mut l.shapes, msx, msy, 1.0, 0.0, 0.0); + } + } + for sl in &mut doc.slides { + scale_shapes(&mut Arc::make_mut(sl).shapes, sx, sy, k, ox, oy); + } + doc.slide_size = size; + Ok(()) + })?; + Ok(json!({"w": size.width, "h": size.height})) +} + +pub(super) fn header_footer(s: &mut Session, p: &Value) -> Result { + s.edit(|doc, sel| { + let hf = &mut doc.header_footer; + if let Some(v) = bool_param(p, "date") { + hf.date = v; + } + if let Some(v) = str_param(p, "dateText") { + hf.date_text = v.into(); + } + if let Some(v) = str_param(p, "dateFormat") { + // Refused rather than ignored: a format that renders nothing would leave the + // placeholder showing its saved text for ever. + if !deckcraft_text::datetime::DATE_KINDS.contains(&v) { + return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13")); + } + hf.date_format = v.into(); + } + if let Some(v) = bool_param(p, "slideNumber") { + hf.slide_number = v; + } + if let Some(v) = bool_param(p, "footer") { + hf.footer = v; + } + if let Some(v) = str_param(p, "footerText") { + hf.footer_text = v.into(); + hf.footer = hf.footer || !v.is_empty(); + } + if let Some(v) = bool_param(p, "hideOnTitle") { + hf.hide_on_title = v; + } + let hf = hf.clone(); + let date_given = str_param(p, "dateText").is_some(); + let format_given = str_param(p, "dateFormat").is_some(); + let all = bool_or(p, "all", true); + let list: Vec = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] }; + for i in list { + let Some(layout) = doc.slides.get(i).map(|x| x.layout) else { continue }; + let lay = doc.layout(layout).map(|(_, l)| l.clone()); + let is_title = lay.as_ref().is_some_and(|l| l.kind == deckcraft_model::LayoutType::Title); + let want = |k: PhType| { + !(hf.hide_on_title && is_title) + && match k { + PhType::Date => hf.date, + PhType::SlideNum => hf.slide_number, + PhType::Footer => hf.footer, + _ => false, + } + }; + let mut new_ids = vec![]; + for _ in 0..3 { + new_ids.push(ShapeId(doc.alloc_id().ok_or_else(|| bad("design.headerFooter", NO_ID_SPACE))?)); + } + let Some(sl) = doc.slides.get_mut(i) else { continue }; + let sl = Arc::make_mut(sl); + for (n, k) in [PhType::Date, PhType::Footer, PhType::SlideNum].into_iter().enumerate() { + let has = sl.shapes.iter().position(|x| x.ph_type() == Some(k)); + match (has, want(k)) { + (Some(pos), false) => { + sl.shapes.remove(pos); + } + (None, true) => { + let lp = lay.as_ref().and_then(|l| l.shapes.iter().find(|x| x.ph_type() == Some(k)).cloned()); + let Some(lp) = lp else { continue }; + let body = match k { + PhType::SlideNum => TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![Run { + text: "‹#›".into(), + props: Default::default(), + kind: RunKind::Field { field: "slidenum".into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date if hf.date_text.is_empty() => TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![Run { + text: date_now(&hf.date_format), + props: Default::default(), + kind: RunKind::Field { field: date_kind(&hf.date_format).into() }, + }], + ..Default::default() + }], + ..Default::default() + }, + PhType::Date => TextBody::from_text(&hf.date_text), + _ => TextBody::from_text(&hf.footer_text), + }; + sl.shapes.push(Shape { + id: new_ids.get(n).copied().unwrap_or_default(), + name: lp.name.clone(), + ph: lp.ph.clone(), + text: Some(body), + ..Default::default() + }); + } + (Some(pos), true) if k == PhType::Footer => { + if let Some(sh) = sl.shapes.get_mut(pos) { + sh.text = Some(TextBody::from_text(&hf.footer_text)); + } + } + (Some(pos), true) if k == PhType::Date && (date_given || format_given) => { + if let Some(sh) = sl.shapes.get_mut(pos) { + set_date(sh, &hf.date_text, &hf.date_format); + } + } + _ => {} + } + } + } + Ok(()) + })?; + ok() +} + +/// The date field type `format` names, `datetime1` when it is empty or unknown. +fn date_kind(format: &str) -> &str { + if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" } +} + +/// The saved text of a date field of `format`, as it shows now. +fn date_now(format: &str) -> String { + let kind = date_kind(format); + deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today) +} + +/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty +/// `fixed`, at an automatic date field (one it already has keeps its format). +fn set_date(sh: &mut Shape, fixed: &str, format: &str) { + let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs); + let field_now = runs().find_map(|r| match &r.kind { + RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()), + _ => None, + }); + let has_field = field_now.is_some(); + let props = runs().next().map(|r| r.props.clone()).unwrap_or_default(); + let want = date_kind(format); + let (text, kind) = match fixed { + // Automatic: keep the field, but follow a format that has changed. + "" if field_now.as_deref() == Some(want) => return, + "" if has_field && format.is_empty() => return, + "" => (date_now(format), RunKind::Field { field: want.into() }), + _ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return, + _ => (fixed.to_string(), RunKind::Text), + }; + let para = deckcraft_model::Paragraph { runs: vec![Run { text, props, kind }], ..Default::default() }; + sh.text = Some(TextBody { paragraphs: vec![para], ..sh.text.clone().unwrap_or_default() }); +} + +/// Today's local date as a `datetime1` field shows it (M/D/YYYY): the saved text of a new date +/// field. Empty where there is no clock (wasm). +pub fn today() -> String { + date_field_text("datetime1") +} + +/// What date field `kind` shows now (empty if unknown or there is no clock). +pub fn date_field_text(kind: &str) -> String { + use deckcraft_text::datetime::{DateTime, field_text}; + DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default() +} + +/// Days since 1970-01-01 → (year, month, day), exact for every `i64`. +pub fn civil(z: i64) -> (i64, u32, u32) { + use chrono::Datelike; + // `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400 + // years (146_097 days). chrono converts the day within its cycle and whole cycles go back + // into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and + // 0..146_097 days from it always fits. + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), + } +} + +#[cfg(test)] +mod header_footer_date_tests { + use deckcraft_model::PhType; + use deckcraft_model::text::RunKind; + use serde_json::json; + + use crate::Session; + + /// The date field of the Date placeholders of every slide: (saved text, field type). + fn dates(s: &Session) -> Vec<(String, Option)> { + let d = &s.doc().unwrap().doc; + d.slides + .iter() + .flat_map(|sl| sl.shapes.iter()) + .filter(|sh| sh.ph_type() == Some(PhType::Date)) + .flat_map(|sh| sh.text.iter()) + .flat_map(|t| t.paragraphs.iter()) + .flat_map(|p| p.runs.iter()) + .map(|r| { + let kind = match &r.kind { + RunKind::Field { field } => Some(field.clone()), + _ => None, + }; + (r.text.clone(), kind) + }) + .collect() + } + + fn sample(kind: &str) -> String { + let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date"); + deckcraft_text::datetime::field_text(kind, None, now).expect("a sample") + } + + /// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field. + /// The clock is read before and after the command, so a second ticking over between the + /// command and the read cannot fail the comparison. Returns the run text. + fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String { + let before = sample(kind); + s.execute("design.headerFooter", ¶ms).unwrap(); + let after = sample(kind); + let got = dates(s); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].1.as_deref(), Some(kind)); + assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}"); + got[0].0.clone() + } + + /// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format` + /// existed for this and was never read: the placeholder was always `datetime1`, and a + /// `dateFormat` parameter was accepted and silently dropped. + #[test] + fn the_automatic_date_takes_the_format_it_is_given() { + let mut s = Session::with_new(); + + // Default: the short date, as before. + run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1"); + + // A format: the field type changes and the saved text is that format's. + run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5"); + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5"); + + // A time format works the same way, and these two differ, so the format is really read. + let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); + assert_ne!(time, sample("datetime5")); + + // Asking again changes nothing (and does not rewrite the run). + let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11"); + assert_eq!(again, time); + + // A fixed date still wins: plain text, no field. + s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap(); + assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]); + + // Back to automatic: the remembered format comes back, not datetime1. + run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11"); + + // An unknown format is refused rather than ignored, and changes nothing. + for bad in ["datetime0", "datetime14", "nonsense", ""] { + assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}"); + } + assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11"); + let got = dates(&s); + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0].1.as_deref(), Some("datetime11")); + } +} diff --git a/crates/engine/src/cmd/design/tests.rs b/crates/engine/src/cmd/design/tests.rs new file mode 100644 index 000000000..9765fcc8f --- /dev/null +++ b/crates/engine/src/cmd/design/tests.rs @@ -0,0 +1,487 @@ +use std::sync::Arc; + +use deckcraft_color::SchemeSlot; +use deckcraft_model::style::PictureMode; +use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes}; +use deckcraft_model::{Background, Fill}; +use serde_json::{Value, json}; + +use crate::{Session, UiRequest}; + +fn scheme(s: &Session) -> deckcraft_color::ColorScheme { + s.doc().unwrap().doc.masters[0].theme.colors.clone() +} + +fn font_scheme(s: &Session) -> FontScheme { + s.doc().unwrap().doc.masters[0].theme.fonts.clone() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn png() -> String { + let mut img = image::RgbaImage::new(2, 2); + img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255])); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + crate::cmd::base64_encode(&bytes) +} + +fn picture(s: &Session) -> deckcraft_model::style::PictureFill { + match &s.doc().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(), + other => panic!("expected a picture background, got {other:?}"), + } +} + +#[test] +fn layout_commands_reject_invalid_index() { + let mut s = Session::with_new(); + s.execute("master.insertLayout", &json!({})).unwrap(); + let count = |s: &Session| s.doc().unwrap().doc.masters.iter().map(|m| m.layouts.len()).sum::(); + let names = |s: &Session| s.doc().unwrap().doc.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.name.clone())).collect::>(); + let n = count(&s); + let before = names(&s); + for bad in [json!(-1), json!(1.5)] { + assert!(s.execute("master.deleteLayout", &json!({"layout": bad})).is_err()); + assert!(s.execute("master.deleteLayout", &json!({"master": bad})).is_err()); + assert!(s.execute("master.renameLayout", &json!({"layout": bad, "name": "x"})).is_err()); + assert_eq!(count(&s), n); + assert_eq!(names(&s), before); + } + s.execute("master.deleteLayout", &json!({})).unwrap(); + assert_eq!(count(&s), n - 1); +} + +#[test] +fn civil_dates() { + assert_eq!(super::civil(0), (1970, 1, 1)); + assert_eq!(super::civil(20_734), (2026, 10, 8)); + // Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes. + assert_eq!(super::civil(95_026_602), (262_144, 1, 1)); + assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1)); + assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27)); + assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7)); +} + +#[test] +fn colors_dialog_apply_and_reject() { + let mut s = Session::with_new(); + let original = scheme(&s); + let n = undo_len(&s); + s.execute("design.colors", &json!({})).unwrap(); + s.execute("design.colors", &Value::Null).unwrap(); + assert_eq!(scheme(&s), original); + assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); + assert_eq!(s.ui_requests.len(), 2); + match &s.ui_requests[0] { + UiRequest::Dialog { id, params } => { + assert_eq!(id, "themeColors"); + assert_eq!(params["name"], "DeckCraft"); + let colors = params["colors"].as_object().unwrap(); + assert_eq!(colors.len(), 12); + assert_eq!(colors["accent1"].as_str().unwrap(), format!("#{}", original.get(SchemeSlot::Accent1).hex())); + assert!(colors.contains_key("folHlink")); + } + other => panic!("expected a dialog, got {other:?}"), + } + + s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); + let harbor = builtin_color_schemes().into_iter().find(|c| c.name == "Harbor").unwrap(); + assert_eq!(scheme(&s).name, "Harbor"); + assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors"); + s.execute("design.colors", &json!({"name": "Harbor"})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); + colors_dialog_rejects(&mut s, n, &harbor); +} + +fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) { + let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no colour scheme `Nope`"), "{err}"); + assert!(s.execute("design.colors", &json!({"name": " "})).is_err()); + assert!(s.execute("design.colors", &json!({"name": 1})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": "red"})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"bg1": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"tx1": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"phClr": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err()); + assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err()); + assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err()); + assert_eq!(scheme(s).name, "Harbor"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(s), n + 1); + colors_dialog_custom_overrides(s, n, harbor); +} + +fn colors_dialog_custom_overrides(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) { + s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap(); + assert_eq!(scheme(s).name, "Custom"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1).hex(), "112233"); + assert_eq!(scheme(s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2)); + assert_eq!(undo_len(s), n + 2); + + let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap(); + s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap(); + assert_eq!(scheme(s).name, "Meadow"); + assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + assert_eq!(undo_len(s), n + 3); + + s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap(); + assert_eq!(scheme(s).name, "Talk"); + assert_eq!(scheme(s).get(SchemeSlot::Dk1).hex(), "ABCDEF"); + assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203"); + assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1)); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(scheme(s).name, "Meadow"); + assert_eq!(scheme(s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1)); +} + +#[test] +fn fonts_dialog_apply_and_reject() { + let mut s = Session::with_new(); + let original = font_scheme(&s); + let n = undo_len(&s); + s.execute("design.fonts", &json!({})).unwrap(); + s.execute("design.fonts", &Value::Null).unwrap(); + s.execute("design.fonts", &json!([1, 2])).unwrap(); + assert_eq!(font_scheme(&s), original); + assert_eq!(undo_len(&s), n, "opening the dialog does not edit"); + assert_eq!(s.ui_requests.len(), 3); + match &s.ui_requests[0] { + UiRequest::Dialog { id, params } => { + assert_eq!(id, "themeFonts"); + assert_eq!(params["name"], original.name); + assert_eq!(params["major"], original.major.latin); + assert_eq!(params["minor"], original.minor.latin); + } + other => panic!("expected a dialog, got {other:?}"), + } + + s.execute("design.fonts", &json!({"name": "classic"})).unwrap(); + let classic = builtin_font_schemes().into_iter().find(|f| f.name == "Classic").unwrap(); + assert_eq!(font_scheme(&s), classic, "a builtin name keeps that scheme, including its own name"); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); + s.execute("design.fonts", &json!({"name": "Classic"})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step"); + + let err = s.execute("design.fonts", &json!({"name": "Nope"})).unwrap_err().to_string(); + assert!(err.contains("no font scheme `Nope`"), "{err}"); + assert!(s.execute("design.fonts", &json!({"name": " "})).is_err()); + assert!(s.execute("design.fonts", &json!({"name": 1})).is_err()); + assert!(s.execute("design.fonts", &json!({"major": ""})).is_err()); + assert!(s.execute("design.fonts", &json!({"major": " "})).is_err()); + assert!(s.execute("design.fonts", &json!({"minor": 1})).is_err()); + assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err()); + assert_eq!(font_scheme(&s), classic); + assert_eq!(undo_len(&s), n + 1); + fonts_dialog_custom_overrides(&mut s); +} + +fn fonts_dialog_custom_overrides(s: &mut Session) { + s.edit(|doc, _| { + let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts; + fonts.major.ea = "EA Major".into(); + fonts.minor.cs = "CS Minor".into(); + fonts.major.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Noto Naskh Arabic".into() }); + Ok(()) + }) + .unwrap(); + let marked = font_scheme(s); + let n = undo_len(s); + + s.execute("design.fonts", &json!({"major": " Lato "})).unwrap(); + let custom = font_scheme(s); + assert_eq!(custom.name, "Custom"); + assert_eq!(custom.major.latin, "Lato"); + assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major"); + assert_eq!(custom.major.ea, "EA Major"); + assert_eq!(custom.minor.cs, "CS Minor"); + assert_eq!(custom.major.scripts, marked.major.scripts); + assert_eq!(undo_len(s), n + 1); + + s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap(); + assert_eq!(font_scheme(s).name, "Custom"); + assert_eq!(font_scheme(s).major.latin, "Lato"); + assert_eq!(font_scheme(s).minor.latin, "Roboto"); + assert_eq!(font_scheme(s).major.ea, "EA Major"); + assert_eq!(undo_len(s), n + 2); + + s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap(); + assert_eq!(font_scheme(s).name, "Classic"); + assert_eq!(font_scheme(s).major.latin, "Inter"); + assert_eq!(font_scheme(s).minor.latin, "Open Sans"); + assert_eq!(font_scheme(s).major.ea, ""); + assert!(font_scheme(s).major.scripts.is_empty(), "a builtin is applied before the latin overlay"); + assert_eq!(undo_len(s), n + 3); + + s.edit(|doc, _| { + std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into(); + Ok(()) + }) + .unwrap(); + s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap(); + assert_eq!(font_scheme(s).name, "Talk"); + assert_eq!(font_scheme(s).major.latin, "Inter"); + assert_eq!(font_scheme(s).minor.latin, "Nunito Sans"); + assert_eq!(font_scheme(s).major.ea, "Kept"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts"); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(font_scheme(s).name, "Classic"); + assert_eq!(font_scheme(s).major.ea, "Kept"); + assert_eq!(font_scheme(s).minor.latin, "Open Sans"); +} + +#[test] +fn background_picture_options_tile_transparency_and_copy() { + let mut s = Session::with_new(); + let n = undo_len(&s); + let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string(); + assert!(err.contains("picture background"), "{err}"); + assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err()); + assert_eq!(undo_len(&s), n); + + s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap(); + match &s.doc().unwrap().doc.slides[0].background { + Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()), + other => panic!("expected a solid background, got {other:?}"), + } + let n = undo_len(&s); + s.execute("design.background", &json!({"transparency": 0.25})).unwrap(); + assert_eq!(undo_len(&s), n, "the same transparency is not an undo step"); + + s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap(); + let pf = picture(&s); + assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6); + assert!(matches!(pf.mode, PictureMode::Tile { .. })); + let n = undo_len(&s); + + s.execute( + "design.background", + &json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}), + ) + .unwrap(); + match picture(&s).mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0)); + assert_eq!(flip, "xy"); + assert_eq!(align, "ctr"); + } + other => panic!("expected a tile, got {other:?}"), + } + assert!(picture(&s).upright); + s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap(); + match picture(&s).mode { + PictureMode::Tile { tx, align, .. } => { + assert_eq!(tx, 12.0); + assert_eq!(align, "ctr"); + } + other => panic!("expected the tile to stay, got {other:?}"), + } + assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step"); + + s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap(); + match picture(&s).mode { + PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]), + other => panic!("expected a stretch, got {other:?}"), + } + + s.execute("slide.new", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.slides[1].background.is_none()); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let n = undo_len(&s); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background"); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step"); +} + +#[test] +fn background_all_applies_to_master_and_new_slides_inherit() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); + assert_eq!(s0["background"], "#121826"); + + let n = undo_len(&s); + s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + + // Idempotency: re-applying the same background to all is a no-op (no extra undo step) + s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step"); + + // Slide 0 has new background, master has new background, layouts cleared + let doc = &s.doc().unwrap().doc; + assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. }))); + assert_eq!(doc.masters[0].background, doc.slides[0].background); + for l in &doc.masters[0].layouts { + assert!(l.background.is_none()); + } + + // Newly added slides inherit the master background (fixes #891) + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap(); + let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); + let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap(); + assert_eq!(s0_after["background"], "#0D0D0D"); + assert_eq!(s1_after["background"], "#0D0D0D"); + + // Undo restores previous slide and master backgrounds + s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new + assert_eq!(s.doc().unwrap().doc.slides.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); // undos design.background + let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap(); + assert_eq!(s0_reverted["background"], "#121826"); +} + +#[test] +fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); + + let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) }; + s.edit(|doc, _| { + let m = Arc::make_mut(&mut doc.masters[0]); + m.layouts[0].background = Some(custom_layout_bg.clone()); + Ok(()) + }) + .unwrap(); + + // Slide 0 follows master (background: None) + assert!(s.doc().unwrap().doc.slides[0].background.is_none()); + // Give Slide 1 an explicit override + s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap(); + assert!(s.doc().unwrap().doc.slides[1].background.is_some()); + + let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); + assert!(orig_master_bg.is_some()); + + // Select slide 0 (which follows master) and apply copy to all + s.execute("slide.go", &json!({"index": 0})).unwrap(); + let n = undo_len(&s); + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + + // Slide 1 has been reset to None (follows master), slide 0 remains None + let doc = &s.doc().unwrap().doc; + assert!(doc.slides[0].background.is_none()); + assert!(doc.slides[1].background.is_none()); + + // Master and layout backgrounds survived! + assert_eq!(doc.masters[0].background, orig_master_bg); + assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); + + // Idempotency: re-applying copy is a no-op + s.execute("design.background", &json!({"copy": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op"); +} + +#[test] +fn background_reset_all_preserves_master_and_layout_backgrounds() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap(); + + let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) }; + s.edit(|doc, _| { + let m = Arc::make_mut(&mut doc.masters[0]); + m.layouts[0].background = Some(custom_layout_bg.clone()); + Ok(()) + }) + .unwrap(); + + let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone(); + assert!(orig_master_bg.is_some()); + + // Set explicit backgrounds on both slides individually (master remains untouched) + s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap(); + s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap(); + assert!(s.doc().unwrap().doc.slides[0].background.is_some()); + assert!(s.doc().unwrap().doc.slides[1].background.is_some()); + + // Now perform reset with all + let n = undo_len(&s); + s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + + // Slides reset to None to follow master + let doc = &s.doc().unwrap().doc; + assert!(doc.slides[0].background.is_none()); + assert!(doc.slides[1].background.is_none()); + + // Master and layout backgrounds survived! + assert_eq!(doc.masters[0].background, orig_master_bg); + assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg)); + + // Idempotency: re-running reset all is a no-op + s.execute("design.background", &json!({"reset": true, "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op"); +} + +#[test] +fn background_all_only_touches_used_masters() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + + // Add a second master to presentation + let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) }; + s.edit(|doc, _| { + let mut m2 = (*doc.masters[0]).clone(); + m2.id = deckcraft_model::MasterId(999); + m2.background = Some(second_master_bg.clone()); + doc.masters.push(Arc::new(m2)); + Ok(()) + }) + .unwrap(); + + assert_eq!(s.doc().unwrap().doc.masters.len(), 2); + // Slides only use master 0 (default layout) + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + + let doc = &s.doc().unwrap().doc; + // Master 0 updated + assert_eq!(doc.masters[0].background, doc.slides[0].background); + // Master 1 preserved! + assert_eq!(doc.masters[1].background, Some(second_master_bg)); +} + +#[test] +fn background_all_clears_layout_overrides_even_if_slides_and_master_match() { + let mut s = Session::with_new(); + s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap(); + + // Set all slides and master to #AA0000 + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background); + + // Add a layout override on layout 0 + let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) }; + s.edit(|doc, _| { + let m = Arc::make_mut(&mut doc.masters[0]); + m.layouts[0].background = Some(custom_bg); + Ok(()) + }) + .unwrap(); + + // Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override. + let n = undo_len(&s); + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op"); + + let doc = &s.doc().unwrap().doc; + assert!(doc.masters[0].layouts[0].background.is_none()); + + // Now that layout override is cleared, repeating is an actual no-op + s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op"); +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index f2955feb7..6e9e75ce8 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -16,6 +16,26 @@ use super::{ use crate::{Result, Session}; use deckcraft_model::ShapeId; +mod font; +mod painter; +mod paragraph; +mod ruler; +mod text_effects; + +pub use font::SIZES; +use font::{ + bold, caps, clear, color, font, font_dialog, grow, highlight, italic, language, language_all, shrink, size, spacing, strike, subscript, + superscript, text_transparency, underline, +}; +pub use painter::Painted; +pub(crate) use painter::apply_painted; +use painter::{font_add, font_rescan, painter_apply, painter_pick, state}; +use paragraph::{ + align, align_to, anchor, autofit, bullets, columns, direction, level, line_spacing, margins, numbering, paragraph, paragraph_dialog, rtl, wrap, +}; +use ruler::{ruler, tabs}; +use text_effects::{case, text_outline, text_shadow, text_warp}; + pub fn specs() -> Vec { vec![ cmd!(noundo "font.add", "Add Font", [], None, "{data: base64 TTF/OTF/TTC} → {faces} added for this session", always, font_add), @@ -287,1237 +307,6 @@ fn current_para(s: &Session) -> ParaProps { } } -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`"))?; - // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels), - // which used to answer and change nothing. - let st = s.doc()?; - if st.selection.text.is_none() { - let charts: Vec = - st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect(); - let only_charts = charts.len() == st.selection.shapes.len(); - if !charts.is_empty() { - let tc = c.clone(); - edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| { - if let ShapeKind::Chart(ch) = &mut sh.kind { - ch.text_color = Some(tc.clone()); - ch.raw = None; - } - Ok(()) - })?; - if only_charts { - return ok(); - } - } - } - format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) -} -/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it. -fn with_transparency(c: &mut ColorRef, transparency: f64) { - c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); - if transparency > 0.0 { - c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32)); - } -} - -/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own -/// fill takes the colour it shows now (from the placeholder, master or theme) so only its -/// opacity changes. -fn text_transparency(s: &mut Session, p: &Value) -> Result { - let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?; - if !v.is_finite() { - return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1")); - } - let v = v.clamp(0.0, 1.0); - let shown = match current_run(s).fill { - Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f, - _ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)), - }; - format_run(s, &move |r| { - let mut f = match &r.fill { - Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(), - Some(Fill::None) => return, - _ => shown.clone(), - }; - match &mut f { - Fill::Solid { color } => with_transparency(color, v), - Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)), - _ => {} - } - r.fill = Some(f); - }) -} -/// The `lang` and `noProof` params of the language commands, checked: a run-props setter. -fn language_edit(id: &str, p: &Value) -> Result { - let lang = str_param(p, "lang").map(str::to_string); - let no_proof = bool_param(p, "noProof"); - if lang.is_none() && no_proof.is_none() { - return Err(bad(id, "give `lang` or `noProof`")); - } - if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) { - return Err(bad(id, "`lang` is a language tag such as en-US")); - } - Ok(move |r: &mut RunProps| { - if let Some(l) = &lang { - r.lang = Some(l.clone()); - } - if let Some(n) = no_proof { - r.no_proof = n.then_some(true); - } - }) -} -/// Review › Language on the selected text (or the selected shapes' text). -fn language(s: &mut Session, p: &Value) -> Result { - let f = language_edit("format.language", p)?; - format_run(s, &f) -} -/// Every run in the deck, and the text styles new text inherits from, in one language. -fn language_all(s: &mut Session, p: &Value) -> Result { - let f = language_edit("format.languageAll", p)?; - /// Runs, end marks and the body's own list style levels (never creating one). - fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) { - ed::format_all(t, f); - t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); - } - /// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep). - fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) { - if depth > 64 { - return; - } - for sh in list { - if let Some(t) = &mut sh.text { - body(t, f); - } - match &mut sh.kind { - deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)), - deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1), - _ => {} - } - } - } - /// A master or presentation text style's levels: what new text inherits. - fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) { - // Level 1 always, so a deck without a default text style gets one. - if l.level(0).is_none() { - l.set(0, Default::default()); - } - l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); - } - s.edit(|doc, _| { - for sl in &mut doc.slides { - let sl = Arc::make_mut(sl); - shapes(&mut sl.shapes, &f, 0); - body(&mut sl.notes, &f); - } - let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()]; - for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) { - let m = Arc::make_mut(m); - shapes(&mut m.shapes, &f, 0); - m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0)); - [&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f)); - } - styles(&mut doc.default_text_style, &f); - Ok(()) - })?; - ok() -} -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)) -} - -/// Character formatting applied together, so the Font dialog is one undo step. -struct FontEdits { - family: Option, - size: Option, - bold: Option, - italic: Option, - underline: Option, - color: Option, - strike: Option, - baseline: Option, - caps: Option, - spacing: Option, -} - -impl FontEdits { - fn is_empty(&self) -> bool { - self.family.is_none() - && self.size.is_none() - && self.bold.is_none() - && self.italic.is_none() - && self.underline.is_none() - && self.color.is_none() - && self.strike.is_none() - && self.baseline.is_none() - && self.caps.is_none() - && self.spacing.is_none() - } - - fn write(&self, r: &mut RunProps) { - if let Some(f) = &self.family { - r.font = Some(f.clone()); - } - if let Some(v) = self.size { - r.size = Some(v); - } - if let Some(v) = self.bold { - r.bold = Some(v); - } - if let Some(v) = self.italic { - r.italic = Some(v); - } - if let Some(u) = &self.underline { - r.underline = Some(u.clone()); - } - if let Some(c) = &self.color { - r.fill = Some(Fill::solid(c.clone())); - } - if let Some(v) = self.strike { - r.strike = Some(v); - } - if let Some(v) = self.baseline { - r.baseline = Some(v); - } - if let Some(v) = self.caps { - r.caps = Some(v); - } - if let Some(v) = self.spacing { - r.spacing = Some(v); - } - } -} - -fn font_bool(p: &Value, key: &str) -> Result> { - match p.get(key) { - None => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), - } -} - -fn font_str<'a>(p: &'a Value, key: &str) -> Result> { - match p.get(key) { - None => Ok(None), - Some(Value::String(s)) => Ok(Some(s)), - Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), - } -} - -fn font_edits(p: &Value) -> Result { - let family = match font_str(p, "family")? { - None => None, - Some(s) => { - let t = s.trim(); - if t.is_empty() || t.chars().count() > 200 { - return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); - } - Some(t.to_string()) - } - }; - let size = match p.get("size") { - None => None, - Some(v) => { - let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; - Some(n.clamp(1.0, 4000.0)) - } - }; - let underline = match font_str(p, "underline")? { - None => None, - Some(s) => { - if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { - return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); - } - Some(s.to_string()) - } - }; - let color = - if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; - let strike = match font_str(p, "strike")? { - None => None, - Some("none") => Some(Strike::None), - Some("sng" | "single") => Some(Strike::Single), - Some("dbl" | "double") => Some(Strike::Double), - Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), - }; - let baseline = match p.get("baseline") { - None => None, - Some(Value::String(s)) => match s.as_str() { - "none" => Some(0.0), - "super" | "superscript" => Some(0.3), - "sub" | "subscript" => Some(-0.25), - _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), - }, - Some(Value::Number(n)) => { - let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; - Some(f.clamp(-1.0, 1.0)) - } - Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), - }; - let caps = match font_str(p, "caps")? { - None => None, - Some("none") => Some(Caps::None), - Some("small") => Some(Caps::Small), - Some("all") => Some(Caps::All), - Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), - }; - let spacing = match p.get("spacing") { - None => None, - Some(v) => { - let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; - Some(n.clamp(-100.0, 400.0)) - } - }; - Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) -} - -/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. -fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { - use deckcraft_model::ColorBase; - let mut o = serde_json::Map::new(); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().to_string(), json!(v)); - } - } - match &c.base { - ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), - ColorBase::Rgb { rgb } => { - o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); - Value::Object(o) - } - ColorBase::Scheme { slot } => { - o.insert("scheme".into(), json!(slot.xml_name())); - Value::Object(o) - } - ColorBase::Preset { name } => json!(name), - ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), - } -} - -fn font_dialog_params(s: &Session) -> Value { - let r = current_run(s); - let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); - let baseline = r.baseline.unwrap_or(0.0); - let mut v = json!({ - "family": family, - "size": r.size.unwrap_or(18.0), - "bold": r.bold.unwrap_or(false), - "italic": r.italic.unwrap_or(false), - "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), - "strike": match r.strike { - Some(Strike::Double) => "dbl", - Some(Strike::Single) => "sng", - _ => "none", - }, - "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, - "caps": match r.caps { - Some(Caps::Small) => "small", - Some(Caps::All) => "all", - _ => "none", - }, - "spacing": r.spacing.unwrap_or(0.0), - }); - if let Some(Fill::Solid { color }) = &r.fill { - v["color"] = color_command_value(color); - } - v -} - -fn font_dialog(s: &mut Session, p: &Value) -> Result { - let edits = font_edits(p)?; - if edits.is_empty() { - s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); - return ok(); - } - format_run(s, &|r| edits.write(r)) -} -fn clear(s: &mut Session, _p: &Value) -> Result { - format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) -} -fn text_shadow(s: &mut Session, p: &Value) -> Result { - let v = on_param(p, Some(current_run(s).shadow.is_some())); - let sh = deckcraft_model::style::Shadow { - color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)), - blur: 3.0, - dist: 2.0, - dir: 45.0, - inner: false, - sx: 1.0, - sy: 1.0, - kx: 0.0, - ky: 0.0, - align: String::new(), - rotate_with_shape: false, - }; - format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None }) -} -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 text_warp(s: &mut Session, p: &Value) -> Result { - let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?; - let clear = preset == "none" || preset == "textNoShape"; - if !clear && !deckcraft_geom::warp::is_supported(preset) { - return Err(bad("format.textWarp", format!("unknown text transform `{preset}`"))); - } - let adj = match p.get("adj") { - None => Vec::new(), - Some(Value::Array(arr)) => { - if arr.len() > 8 { - return Err(bad("format.textWarp", "at most 8 adjust values")); - } - let mut v = Vec::with_capacity(arr.len()); - for a in arr { - let Some(n) = a.as_f64().filter(|n| n.is_finite()) else { - return Err(bad("format.textWarp", "adjust values must be finite numbers")); - }; - v.push(n.clamp(-2.0e9, 2.0e9)); - } - v - } - Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")), - }; - let stored = if clear { None } else { Some(preset.to_string()) }; - format_body(s, &|b| { - b.warp.clone_from(&stored); - b.warp_adj.clone_from(&adj); - }) -} - -fn case(s: &mut Session, p: &Value) -> Result { - let mode = str_param(p, "mode").unwrap_or("cycle").to_string(); - let st = s.doc()?; - if let Some(t) = st.selection.text.clone() { - let (mut a, mut b) = t.ordered(); - let body = super::text::body_of(st, &t).cloned().unwrap_or_default(); - let collapsed = a == b; - if collapsed { - // Shift+F3 with a bare caret changes the word it is in and puts the caret at its start. - let (wa, wb) = ed::word_at(&body, a); - let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric); - if !(is_word && wa.1 <= a.1 && a.1 < wb.1) { - return ok(); - } - (a, b) = (wa, wb); - } - let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() }; - super::text::edit_text(s, move |body, t| { - let last = body.paragraphs.len().saturating_sub(1); - for pi in a.0..=b.0.min(last) { - let Some(para) = body.paragraphs.get_mut(pi) else { continue }; - let n = para.char_len(); - let from = if pi == a.0 { a.1 } else { 0 }; - let to = if pi == b.0 { b.1 } else { n }; - let mut pos = 0; - let mut pieces = Vec::new(); - for (ri, r) in para.runs.iter().enumerate() { - // Breaks and fields are read for context (a break ends a word) but not rewritten. - let len = r.char_len(); - let rs = from.max(pos).saturating_sub(pos); - let re = to.min(pos + len).saturating_sub(pos); - if re > rs { - pieces.push((ri, rs, re)); - } - pos += len; - } - let mids: Vec = pieces - .iter() - .map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default()) - .collect(); - let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::>(), &m); - for (&(ri, rs, re), new) in pieces.iter().zip(changed) { - let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue }; - let pre: String = r.text.chars().take(rs).collect(); - let post: String = r.text.chars().skip(re).collect(); - r.text = format!("{pre}{new}{post}"); - } - } - if collapsed { - t.anchor = a; - t.caret = a; - } - })?; - return ok(); - } - let mode = if mode == "cycle" { - let all: String = - st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::>().join( - " -", - ); - ed::next_case(&all).to_string() - } else { - mode - }; - edit_shapes(s, &json!({}), "format.case", |sh| { - if let Some(t) = sh.text.as_mut() { - for para in &mut t.paragraphs { - // Breaks and fields are read for context (a break ends a word) but not rewritten. - let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect(); - let changed = ed::change_case_parts(&texts, &mode); - for (r, new) in para.runs.iter_mut().zip(changed) { - if r.kind == deckcraft_model::text::RunKind::Text { - r.text = new; - } - } - } - } - Ok(()) - }) -} - -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, - "justifyLow" | "justLow" => Align::JustLow, - "distributed" | "dist" => Align::Distributed, - _ => Align::Left, - }; - align_to(s, a) -} - -/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat -/// at the start edge stays there. -fn rtl(s: &mut Session, p: &Value) -> Result { - let on = on_param(p, current_para(s).rtl); - format_para(s, &move |para| { - if para.props.rtl.unwrap_or(false) != on { - para.props.align = match para.props.align { - None | Some(Align::Left) => Some(Align::Right), - Some(Align::Right) => Some(Align::Left), - other => other, - }; - } - para.props.rtl = Some(on); - }) -} - -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).clamp(0.25, 4.0)); - let explicit_char = p.get("char").is_some(); - format_para(s, &move |para| { - if on { - para.props.bullet = Some(Bullet::Char { char: ch.clone() }); - para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() }; - if col.is_some() { - para.props.bullet_color = col.clone(); - } - if size.is_some() { - para.props.bullet_size = size; - } - if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) { - para.props.margin_left = Some(18.0 + 36.0 * para.level as f64); - para.props.indent = Some(-18.0); - } - } else { - para.props.bullet = Some(Bullet::None); - para.props.indent = Some(0.0); - para.props.margin_left = Some(36.0 * para.level as f64); - } - }) -} - -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); - let col = color_param(p, "color"); - let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); - format_para(s, &move |para| { - if on { - para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start }); - if col.is_some() { - para.props.bullet_color = col.clone(); - } - if size.is_some() { - para.props.bullet_size = size; - } - para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); - para.props.indent = Some(-27.0); - } else { - para.props.bullet = Some(Bullet::None); - para.props.indent = Some(0.0); - para.props.margin_left = Some(36.0 * para.level as f64); - } - }) -} - -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))); - } - }) -} - -const PARA_DIALOG_KEYS: &[&str] = - &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; - -fn para_dialog_has_fields(p: &Value) -> bool { - PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) -} - -fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { - match p.get(key) { - None => Ok(None), - Some(v) => { - let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; - Ok(Some(n)) - } - } -} - -fn clamp_pt(v: f64) -> f64 { - v.clamp(0.0, 1584.0) -} - -fn parse_align(cmd: &str, s: &str) -> Result { - Ok(match s { - "left" | "l" => Align::Left, - "center" | "ctr" => Align::Center, - "right" | "r" => Align::Right, - "justify" | "just" => Align::Justify, - "justLow" => Align::JustLow, - "distributed" | "dist" => Align::Distributed, - _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), - }) -} - -fn align_name(a: Align) -> &'static str { - match a { - Align::Left => "left", - Align::Center => "center", - Align::Right => "right", - Align::Justify => "justify", - Align::JustLow => "justLow", - Align::Distributed => "distributed", - } -} - -fn spacing_parts(s: Option) -> (&'static str, f64) { - match s { - Some(Spacing::Pct(v)) => ("pct", v), - Some(Spacing::Pts(v)) => ("pts", v), - None => ("pts", 0.0), - } -} - -fn paragraph_dialog_params(s: &Session) -> Value { - let pp = current_para(s); - let indent = pp.indent.unwrap_or(0.0); - let (special, by) = if indent > 0.05 { - ("first", indent) - } else if indent < -0.05 { - ("hanging", -indent) - } else { - ("none", 0.0) - }; - let (line_kind, line_value) = match pp.line_spacing { - Some(Spacing::Pts(v)) => ("exactly", v), - Some(Spacing::Pct(v)) => ("multiple", v), - None => ("multiple", 1.0), - }; - let (before_kind, before) = spacing_parts(pp.space_before); - let (after_kind, after) = spacing_parts(pp.space_after); - let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); - json!({ - "align": align_name(pp.align.unwrap_or(Align::Left)), - "indentLeft": pp.margin_left.unwrap_or(0.0), - "indentRight": pp.margin_right.unwrap_or(0.0), - "special": special, - "specialBy": by, - "spaceBefore": before, - "spaceBeforeKind": before_kind, - "spaceAfter": after, - "spaceAfterKind": after_kind, - "lineKind": line_kind, - "lineValue": line_value, - "tabs": tabs, - "defaultTab": pp.default_tab.unwrap_or(72.0), - }) -} - -fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { - if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } -} - -fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "format.paragraphDialog"; - if !para_dialog_has_fields(p) { - s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); - return ok(); - } - let align = match p.get("align") { - None => None, - Some(Value::String(a)) => Some(parse_align(CMD, a)?), - Some(_) => return Err(bad(CMD, "`align` must be a string")), - }; - let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); - let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); - let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); - let indent = match p.get("special") { - None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), - Some(Value::String(kind)) => { - let by = special_by.unwrap_or(0.0); - Some(match kind.as_str() { - "none" => 0.0, - "first" | "firstLine" => by, - "hanging" | "hang" => -by, - _ => return Err(bad(CMD, "`special` must be none, first or hanging")), - }) - } - Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), - }; - let space_before = finite_key(p, CMD, "spaceBefore")?; - let space_after = finite_key(p, CMD, "spaceAfter")?; - let before_kind = str_param(p, "spaceBeforeKind"); - let after_kind = str_param(p, "spaceAfterKind"); - for kind in [before_kind, after_kind].into_iter().flatten() { - if !matches!(kind, "pts" | "pct") { - return Err(bad(CMD, "spacing kind must be pts or pct")); - } - } - let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); - let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); - format_para(s, &move |para| { - if align.is_some() { - para.props.align = align; - } - if indent_left.is_some() { - para.props.margin_left = indent_left; - } - if indent_right.is_some() { - para.props.margin_right = indent_right; - } - if indent.is_some() { - para.props.indent = indent; - } - if let Some(v) = space_before { - para.props.space_before = Some(write_spacing(before_kind, v)); - } - if let Some(v) = space_after { - para.props.space_after = Some(write_spacing(after_kind, v)); - } - if let Some(v) = line_mult { - para.props.line_spacing = Some(Spacing::Pct(v)); - } - if let Some(v) = line_pt { - para.props.line_spacing = Some(Spacing::Pts(v)); - } - }) -} - -fn tab_align(s: &str) -> Option<&'static str> { - Some(match s { - "l" | "left" => "l", - "ctr" | "center" => "ctr", - "r" | "right" => "r", - "dec" | "decimal" => "dec", - _ => return None, - }) -} - -fn tabs_params(s: &Session) -> Value { - let pp = current_para(s); - let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); - json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) -} - -fn anchor(s: &mut Session, p: &Value) -> Result { - let a = match str_param(p, "anchor").unwrap_or("top") { - "middle" | "center" | "ctr" => Anchor::Middle, - "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)) -} -/// The edited or first selected shape's columns as drawn: the count and the spacing in points. -fn current_columns(s: &Session) -> (u32, f64) { - let Ok(st) = s.doc() else { return (1, 0.0) }; - let id = match &st.selection.text { - Some(t) if !t.notes => Some(t.shape), - _ => st.selection.shapes.first().copied(), - }; - let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) }; - let body = match super::text::ctx_for(&st.doc, &st.selection) { - Some(c) => resolve::body(&c, sh), - None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), - }; - (body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0)) -} -fn columns(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "format.columns"; - let count = match p.get("count") { - None => None, - Some(v) => { - let n = v - .as_f64() - .filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n)) - .ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?; - Some(n as u32) - } - }; - let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt); - if count.is_none() && spacing.is_none() { - let (count, spacing) = current_columns(s); - s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) }); - return ok(); - } - format_body(s, &move |b| { - if let Some(n) = count { - b.columns = Some(n); - } - if let Some(sp) = spacing { - b.col_spacing = Some(sp); - } - }) -} -fn autofit(s: &mut Session, p: &Value) -> Result { - let m = match str_param(p, "mode").unwrap_or("none") { - "shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }, - "resize" | "shape" => AutoFit::Shape, - _ => AutoFit::None, - }; - format_body(s, &move |b| b.autofit = Some(m)) -} -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()); - } -} - -/// The paragraphs the text selection covers whole: it starts at or before their start and -/// reaches their end. A paragraph the selection only touches the start of (it ends at offset -/// 0, as after a triple-click) is not covered. -fn covered_paragraphs(s: &Session) -> Vec { - let Ok(st) = s.doc() else { return vec![] }; - let Some(t) = &st.selection.text else { return vec![] }; - let Some(body) = super::text::body_of(st, t) else { return vec![] }; - let (a, b) = t.ordered(); - // A hostile selection can point past the body: walk only the paragraphs it has. - (a.0..=b.0.min(body.paragraphs.len().saturating_sub(1))) - .filter(|&pi| { - let Some(para) = body.paragraphs.get(pi) else { return false }; - let starts = pi > a.0 || a.1 == 0; - let ends = if pi < b.0 { - true - } else if b.0 > a.0 && b.1 == 0 { - false - } else { - b.1 >= para.char_len() - }; - starts && ends - }) - .collect() -} - -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())?; - } - // Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the - // selection covers whole; painting part of a bulleted line changes just its text. - let covered = covered_paragraphs(s); - if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) { - super::text::edit_text(s, |body, _| { - for &pi in &covered { - if let Some(para) = body.paragraphs.get_mut(pi) { - para.props = pp.clone(); - } - } - })?; - } - return ok(); - } - edit_shapes(s, p, "format.painterApply", |sh| { - apply_painted(sh, &painted); - Ok(()) - }) -} - -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 { .. })), - "lang": r.lang, - "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), - "painter": s.painter.is_some(), - })) -} - -/// Add a font from its bytes for this session (it then shows in the font menu and draws). -fn font_add(_s: &mut Session, p: &Value) -> Result { - let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?; - if data.len() > 64 << 20 { - return Err(bad("font.add", "font file larger than 64 MB")); - } - let faces = deckcraft_fonts::FontDb::global().add_font(data); - if faces == 0 { - return Err(bad("font.add", "not a font DeckCraft can read (or already added)")); - } - Ok(json!({"faces": faces})) -} - -/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear. -fn font_rescan(_s: &mut Session, _p: &Value) -> Result { - let db = deckcraft_fonts::FontDb::global(); - #[cfg(not(target_arch = "wasm32"))] - let faces = db.load_system_fonts(); - #[cfg(target_arch = "wasm32")] - let faces = 0usize; - Ok(json!({"faces": faces, "families": db.families().len()})) -} - -/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, -/// and where its text area sits on the slide. -fn ruler(s: &mut Session, _p: &Value) -> Result { - let props = current_para(s); - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; - let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { - (false, Some(sh)) => { - let ctx = super::text::ctx_for(&st.doc, &st.selection); - let x = match &ctx { - Some(c) => resolve::xfrm(c, sh), - None => sh.xfrm.unwrap_or_default(), - }; - let body = match &ctx { - Some(c) => resolve::body(c, sh), - None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), - }; - let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); - (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) - } - _ => (0.0, 0.0, 0.0, true), - }; - let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); - Ok(json!({ - "x": x, "y": y, "width": w, "rotated": rotated, - "marginLeft": props.margin_left.unwrap_or(0.0), - "indent": props.indent.unwrap_or(0.0), - "tabs": tabs, - "defaultTab": props.default_tab.unwrap_or(72.0), - })) -} - -const MAX_TABS: usize = 32; - -fn tabs(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "format.tabs"; - let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); - let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { - "ctr" | "center" => "ctr", - "r" | "right" => "r", - "dec" | "decimal" => "dec", - _ => "l", - }; - let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); - let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); - let remove = fin(p.get("remove").and_then(Value::as_f64)); - // `set` replaces every stop: entries without a usable position are dropped, an unknown - // alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins). - let set = match p.get("set") { - None => None, - Some(Value::Array(items)) => { - if items.len() > 64 { - return Err(bad(CMD, "`set` has more than 64 tab stops")); - } - let mut out: Vec = Vec::new(); - for (i, item) in items.iter().enumerate() { - let Some(obj) = item.as_object() else { - return Err(bad(CMD, format!("tab stop {i} must be an object"))); - }; - let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else { - continue; - }; - let align = match obj.get("align").and_then(Value::as_str) { - None => "l", - Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, - }; - if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { - *prev = TabStop { pos, align: align.to_string() }; - } else { - out.push(TabStop { pos, align: align.to_string() }); - } - } - Some(out) - } - Some(_) => return Err(bad(CMD, "`set` must be an array")), - }; - let clear = bool_or(p, "clear", false); - let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); - let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear; - if !edits_stops && default_tab.is_none() { - // No fields: open the Tabs dialog on the caret's paragraph. - s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); - return ok(); - } - // The stops as the caret's paragraph shows them (inherited ones included), so editing a - // placeholder's tab that came from its layout works like editing one set on the paragraph. - let base = current_para(s).tabs.unwrap_or_default(); - let near = |a: f64, b: f64| (a - b).abs() < 0.5; - let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; - if let Some(at) = remove { - stops.retain(|t| !near(t.pos, at)); - } - if let Some((from, to)) = mv - && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) - { - t.pos = to; - } - if let Some((pos, align)) = add { - stops.retain(|t| !near(t.pos, pos)); - stops.push(TabStop { pos, align }); - } - stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); - stops.dedup_by(|a, b| near(a.pos, b.pos)); - stops.truncate(MAX_TABS); - format_para(s, &move |para| { - if edits_stops { - para.props.tabs = Some(stops.clone()); - } - if let Some(d) = default_tab { - para.props.default_tab = Some(d); - } - }) -} - #[cfg(test)] #[path = "ruler_tests.rs"] mod ruler_tests; diff --git a/crates/engine/src/cmd/format/font.rs b/crates/engine/src/cmd/format/font.rs new file mode 100644 index 000000000..11af99b7e --- /dev/null +++ b/crates/engine/src/cmd/format/font.rs @@ -0,0 +1,457 @@ +use super::{ + Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or, + bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param, +}; + +pub(super) fn bold(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, current_run(s).bold); + format_run(s, &move |r| r.bold = Some(v)) +} + +pub(super) fn italic(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, current_run(s).italic); + format_run(s, &move |r| r.italic = Some(v)) +} + +pub(super) fn underline(s: &mut Session, p: &Value) -> Result { + let cur = current_run(s).underline.is_some_and(|u| u != "none"); + let v = on_param(p, Some(cur)); + let style = str_param(p, "style").unwrap_or("sng").to_string(); + format_run(s, &move |r| r.underline = Some(if v { style.clone() } else { "none".into() })) +} + +pub(super) fn strike(s: &mut Session, p: &Value) -> Result { + let cur = current_run(s).strike.is_some_and(|x| x != Strike::None); + let v = on_param(p, Some(cur)); + let d = bool_or(p, "double", false); + format_run(s, &move |r| { + r.strike = Some(if !v { + Strike::None + } else if d { + Strike::Double + } else { + Strike::Single + }) + }) +} + +pub(super) fn superscript(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) > 0.0)); + format_run(s, &move |r| r.baseline = Some(if v { 0.3 } else { 0.0 })) +} + +pub(super) fn subscript(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).baseline.unwrap_or(0.0) < 0.0)); + format_run(s, &move |r| r.baseline = Some(if v { -0.25 } else { 0.0 })) +} + +pub(super) fn font(s: &mut Session, p: &Value) -> Result { + let f = str_param(p, "family").ok_or_else(|| bad("format.font", "missing `family`"))?.to_string(); + format_run(s, &move |r| r.font = Some(f.clone())) +} + +pub(super) fn size(s: &mut Session, p: &Value) -> Result { + 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)); + }) +} + +pub(super) fn grow(s: &mut Session, _p: &Value) -> Result { + grow_shrink(s, true) +} + +pub(super) fn shrink(s: &mut Session, _p: &Value) -> Result { + grow_shrink(s, false) +} + +pub(super) fn color(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; + // A selected chart has no runs: Font Color colours its text (title, axes, legend, labels), + // which used to answer and change nothing. + let st = s.doc()?; + if st.selection.text.is_none() { + let charts: Vec = + st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect(); + let only_charts = charts.len() == st.selection.shapes.len(); + if !charts.is_empty() { + let tc = c.clone(); + edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.text_color = Some(tc.clone()); + ch.raw = None; + } + Ok(()) + })?; + if only_charts { + return ok(); + } + } + } + format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) +} + +/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it. +fn with_transparency(c: &mut ColorRef, transparency: f64) { + c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if transparency > 0.0 { + c.mods.push(ColorTransform::Alpha(((1.0 - transparency) * 100_000.0).round() as i32)); + } +} + +/// Text Fill › Transparency: the text keeps its colour and gets an alpha. A run without its own +/// fill takes the colour it shows now (from the placeholder, master or theme) so only its +/// opacity changes. +pub(super) fn text_transparency(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "value").ok_or_else(|| bad("format.textTransparency", "missing `value` (0–1)"))?; + if !v.is_finite() { + return Err(bad("format.textTransparency", "`value` must be a number from 0 to 1")); + } + let v = v.clamp(0.0, 1.0); + let shown = match current_run(s).fill { + Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f, + _ => Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)), + }; + format_run(s, &move |r| { + let mut f = match &r.fill { + Some(f @ (Fill::Solid { .. } | Fill::Gradient(_))) => f.clone(), + Some(Fill::None) => return, + _ => shown.clone(), + }; + match &mut f { + Fill::Solid { color } => with_transparency(color, v), + Fill::Gradient(g) => g.stops.iter_mut().for_each(|st| with_transparency(&mut st.color, v)), + _ => {} + } + r.fill = Some(f); + }) +} + +/// The `lang` and `noProof` params of the language commands, checked: a run-props setter. +fn language_edit(id: &str, p: &Value) -> Result { + let lang = str_param(p, "lang").map(str::to_string); + let no_proof = bool_param(p, "noProof"); + if lang.is_none() && no_proof.is_none() { + return Err(bad(id, "give `lang` or `noProof`")); + } + if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) { + return Err(bad(id, "`lang` is a language tag such as en-US")); + } + Ok(move |r: &mut RunProps| { + if let Some(l) = &lang { + r.lang = Some(l.clone()); + } + if let Some(n) = no_proof { + r.no_proof = n.then_some(true); + } + }) +} + +/// Review › Language on the selected text (or the selected shapes' text). +pub(super) fn language(s: &mut Session, p: &Value) -> Result { + let f = language_edit("format.language", p)?; + format_run(s, &f) +} + +/// Every run in the deck, and the text styles new text inherits from, in one language. +pub(super) fn language_all(s: &mut Session, p: &Value) -> Result { + let f = language_edit("format.languageAll", p)?; + /// Runs, end marks and the body's own list style levels (never creating one). + fn body(t: &mut deckcraft_model::text::TextBody, f: &dyn Fn(&mut RunProps)) { + ed::format_all(t, f); + t.list_style.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); + } + /// Every text body in `list`: shapes, table cells and groups (nested at most 64 deep). + fn shapes(list: &mut [Shape], f: &dyn Fn(&mut RunProps), depth: usize) { + if depth > 64 { + return; + } + for sh in list { + if let Some(t) = &mut sh.text { + body(t, f); + } + match &mut sh.kind { + deckcraft_model::ShapeKind::Table(t) => t.rows.iter_mut().flat_map(|r| &mut r.cells).for_each(|c| body(&mut c.text, f)), + deckcraft_model::ShapeKind::Group { children, .. } => shapes(children, f, depth + 1), + _ => {} + } + } + } + /// A master or presentation text style's levels: what new text inherits. + fn styles(l: &mut deckcraft_model::text::ListStyle, f: &dyn Fn(&mut RunProps)) { + // Level 1 always, so a deck without a default text style gets one. + if l.level(0).is_none() { + l.set(0, Default::default()); + } + l.levels.iter_mut().flatten().for_each(|lv| f(&mut lv.run)); + } + s.edit(|doc, _| { + for sl in &mut doc.slides { + let sl = Arc::make_mut(sl); + shapes(&mut sl.shapes, &f, 0); + body(&mut sl.notes, &f); + } + let extra = [doc.notes_master.as_mut(), doc.handout_master.as_mut()]; + for m in doc.masters.iter_mut().chain(extra.into_iter().flatten()) { + let m = Arc::make_mut(m); + shapes(&mut m.shapes, &f, 0); + m.layouts.iter_mut().for_each(|l| shapes(&mut l.shapes, &f, 0)); + [&mut m.title_style, &mut m.body_style, &mut m.other_style].into_iter().for_each(|l| styles(l, &f)); + } + styles(&mut doc.default_text_style, &f); + Ok(()) + })?; + ok() +} + +pub(super) fn highlight(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color"); + format_run(s, &move |r| r.highlight = c.clone()) +} + +pub(super) fn caps(s: &mut Session, p: &Value) -> Result { + let c = match str_param(p, "caps").unwrap_or("none") { + "small" => Caps::Small, + "all" => Caps::All, + _ => Caps::None, + }; + format_run(s, &move |r| r.caps = Some(c)) +} + +pub(super) fn spacing(s: &mut Session, p: &Value) -> Result { + let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0); + format_run(s, &move |r| r.spacing = Some(v)) +} + +/// Character formatting applied together, so the Font dialog is one undo step. +struct FontEdits { + family: Option, + size: Option, + bold: Option, + italic: Option, + underline: Option, + color: Option, + strike: Option, + baseline: Option, + caps: Option, + spacing: Option, +} + +impl FontEdits { + fn is_empty(&self) -> bool { + self.family.is_none() + && self.size.is_none() + && self.bold.is_none() + && self.italic.is_none() + && self.underline.is_none() + && self.color.is_none() + && self.strike.is_none() + && self.baseline.is_none() + && self.caps.is_none() + && self.spacing.is_none() + } + + fn write(&self, r: &mut RunProps) { + if let Some(f) = &self.family { + r.font = Some(f.clone()); + } + if let Some(v) = self.size { + r.size = Some(v); + } + if let Some(v) = self.bold { + r.bold = Some(v); + } + if let Some(v) = self.italic { + r.italic = Some(v); + } + if let Some(u) = &self.underline { + r.underline = Some(u.clone()); + } + if let Some(c) = &self.color { + r.fill = Some(Fill::solid(c.clone())); + } + if let Some(v) = self.strike { + r.strike = Some(v); + } + if let Some(v) = self.baseline { + r.baseline = Some(v); + } + if let Some(v) = self.caps { + r.caps = Some(v); + } + if let Some(v) = self.spacing { + r.spacing = Some(v); + } + } +} + +fn font_bool(p: &Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))), + } +} + +fn font_str<'a>(p: &'a Value, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))), + } +} + +fn font_edits(p: &Value) -> Result { + let family = match font_str(p, "family")? { + None => None, + Some(s) => { + let t = s.trim(); + if t.is_empty() || t.chars().count() > 200 { + return Err(bad("format.fontDialog", "`family` must be 1–200 characters")); + } + Some(t.to_string()) + } + }; + let size = match p.get("size") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?; + Some(n.clamp(1.0, 4000.0)) + } + }; + let underline = match font_str(p, "underline")? { + None => None, + Some(s) => { + if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") { + return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy")); + } + Some(s.to_string()) + } + }; + let color = + if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None }; + let strike = match font_str(p, "strike")? { + None => None, + Some("none") => Some(Strike::None), + Some("sng" | "single") => Some(Strike::Single), + Some("dbl" | "double") => Some(Strike::Double), + Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")), + }; + let baseline = match p.get("baseline") { + None => None, + Some(Value::String(s)) => match s.as_str() { + "none" => Some(0.0), + "super" | "superscript" => Some(0.3), + "sub" | "subscript" => Some(-0.25), + _ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }, + Some(Value::Number(n)) => { + let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?; + Some(f.clamp(-1.0, 1.0)) + } + Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")), + }; + let caps = match font_str(p, "caps")? { + None => None, + Some("none") => Some(Caps::None), + Some("small") => Some(Caps::Small), + Some("all") => Some(Caps::All), + Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")), + }; + let spacing = match p.get("spacing") { + None => None, + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?; + Some(n.clamp(-100.0, 400.0)) + } + }; + Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing }) +} + +/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms. +fn color_command_value(c: &deckcraft_model::ColorRef) -> Value { + use deckcraft_model::ColorBase; + let mut o = serde_json::Map::new(); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().to_string(), json!(v)); + } + } + match &c.base { + ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())), + ColorBase::Rgb { rgb } => { + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + Value::Object(o) + } + ColorBase::Scheme { slot } => { + o.insert("scheme".into(), json!(slot.xml_name())); + Value::Object(o) + } + ColorBase::Preset { name } => json!(name), + ColorBase::System { last, .. } => json!(format!("#{}", last.hex())), + } +} + +fn font_dialog_params(s: &Session) -> Value { + let r = current_run(s); + let family = + s.doc().ok().and_then(|st| crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default(); + let baseline = r.baseline.unwrap_or(0.0); + let mut v = json!({ + "family": family, + "size": r.size.unwrap_or(18.0), + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"), + "strike": match r.strike { + Some(Strike::Double) => "dbl", + Some(Strike::Single) => "sng", + _ => "none", + }, + "baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" }, + "caps": match r.caps { + Some(Caps::Small) => "small", + Some(Caps::All) => "all", + _ => "none", + }, + "spacing": r.spacing.unwrap_or(0.0), + }); + if let Some(Fill::Solid { color }) = &r.fill { + v["color"] = color_command_value(color); + } + v +} + +pub(super) fn font_dialog(s: &mut Session, p: &Value) -> Result { + let edits = font_edits(p)?; + if edits.is_empty() { + s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) }); + return ok(); + } + format_run(s, &|r| edits.write(r)) +} + +pub(super) fn clear(s: &mut Session, _p: &Value) -> Result { + format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() }) +} diff --git a/crates/engine/src/cmd/format/painter.rs b/crates/engine/src/cmd/format/painter.rs new file mode 100644 index 000000000..1d324811e --- /dev/null +++ b/crates/engine/src/cmd/format/painter.rs @@ -0,0 +1,148 @@ +use super::{ + Bullet, Effects, Fill, Line, ParaProps, Result, RunProps, Session, Shape, Strike, Value, bad, base64_decode, bool_or, current_para, current_run, + ed, edit_shapes, format_run, ids_param, json, ok, resolve, str_param, +}; + +/// Formatting picked up by the Format Painter. +#[derive(Clone, Debug, Default)] +pub struct Painted { + pub run: Option, + pub para: Option, + pub fill: Option, + pub line: Option, + pub effects: Option, + pub style: Option, +} + +pub(super) 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) = crate::cmd::text::body_of(st, t) { + let a = t.ordered().0; + painted.run = Some(ed::props_at(b, (a.0, a.1 + 1))); + painted.para = b.paragraphs.get(a.0).map(|x| x.props.clone()); + } + } else if let Some(sh) = st.selected_shapes().first() { + painted.fill = sh.fill.clone(); + painted.line = sh.line.clone(); + painted.effects = sh.effects.clone(); + painted.style = sh.style.clone(); + painted.run = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()); + } + s.painter = Some((painted, bool_or(p, "sticky", false))); + Ok(json!({"armed": true})) +} + +pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) { + if p.fill.is_some() || p.style.is_some() { + sh.fill.clone_from(&p.fill); + sh.line.clone_from(&p.line); + sh.effects.clone_from(&p.effects); + sh.style.clone_from(&p.style); + } + if let (Some(r), Some(t)) = (&p.run, sh.text.as_mut()) { + ed::format_all(t, &|rp| *rp = r.clone()); + } +} + +/// The paragraphs the text selection covers whole: it starts at or before their start and +/// reaches their end. A paragraph the selection only touches the start of (it ends at offset +/// 0, as after a triple-click) is not covered. +fn covered_paragraphs(s: &Session) -> Vec { + let Ok(st) = s.doc() else { return vec![] }; + let Some(t) = &st.selection.text else { return vec![] }; + let Some(body) = crate::cmd::text::body_of(st, t) else { return vec![] }; + let (a, b) = t.ordered(); + // A hostile selection can point past the body: walk only the paragraphs it has. + (a.0..=b.0.min(body.paragraphs.len().saturating_sub(1))) + .filter(|&pi| { + let Some(para) = body.paragraphs.get(pi) else { return false }; + let starts = pi > a.0 || a.1 == 0; + let ends = if pi < b.0 { + true + } else if b.0 > a.0 && b.1 == 0 { + false + } else { + b.1 >= para.char_len() + }; + starts && ends + }) + .collect() +} + +pub(super) fn painter_apply(s: &mut Session, p: &Value) -> Result { + let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) }; + if !sticky { + s.painter = None; + } + if s.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) && ids_param(p, "ids").is_none() { + if let Some(r) = painted.run.clone() { + format_run(s, &move |rp| *rp = r.clone())?; + } + // Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the + // selection covers whole; painting part of a bulleted line changes just its text. + let covered = covered_paragraphs(s); + if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) { + crate::cmd::text::edit_text(s, |body, _| { + for &pi in &covered { + if let Some(para) = body.paragraphs.get_mut(pi) { + para.props = pp.clone(); + } + } + })?; + } + return ok(); + } + edit_shapes(s, p, "format.painterApply", |sh| { + apply_painted(sh, &painted); + Ok(()) + }) +} + +pub(super) fn state(s: &mut Session, _p: &Value) -> Result { + let r = current_run(s); + let pp = current_para(s); + let st = s.doc()?; + let family = crate::cmd::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r)).unwrap_or_default(); + Ok(json!({ + "font": family, + "fontRef": r.font, + "size": r.size, + "bold": r.bold.unwrap_or(false), + "italic": r.italic.unwrap_or(false), + "underline": r.underline.as_deref().is_some_and(|u| u != "none"), + "strike": r.strike.is_some_and(|x| x != Strike::None), + "superscript": r.baseline.unwrap_or(0.0) > 0.0, + "subscript": r.baseline.unwrap_or(0.0) < 0.0, + "align": pp.align.map(|a| a.xml()), + "bullets": matches!(pp.bullet, Some(Bullet::Char { .. })), + "numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })), + "lang": r.lang, + "lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(), + "painter": s.painter.is_some(), + })) +} + +/// Add a font from its bytes for this session (it then shows in the font menu and draws). +pub(super) fn font_add(_s: &mut Session, p: &Value) -> Result { + let data = str_param(p, "data").and_then(base64_decode).ok_or_else(|| bad("font.add", "missing or invalid base64 `data`"))?; + if data.len() > 64 << 20 { + return Err(bad("font.add", "font file larger than 64 MB")); + } + let faces = deckcraft_fonts::FontDb::global().add_font(data); + if faces == 0 { + return Err(bad("font.add", "not a font DeckCraft can read (or already added)")); + } + Ok(json!({"faces": faces})) +} + +/// Catalog the installed fonts again, so fonts installed while DeckCraft runs appear. +pub(super) fn font_rescan(_s: &mut Session, _p: &Value) -> Result { + let db = deckcraft_fonts::FontDb::global(); + #[cfg(not(target_arch = "wasm32"))] + let faces = db.load_system_fonts(); + #[cfg(target_arch = "wasm32")] + let faces = 0usize; + Ok(json!({"faces": faces, "families": db.families().len()})) +} diff --git a/crates/engine/src/cmd/format/paragraph.rs b/crates/engine/src/cmd/format/paragraph.rs new file mode 100644 index 000000000..dd88f4055 --- /dev/null +++ b/crates/engine/src/cmd/format/paragraph.rs @@ -0,0 +1,416 @@ +use super::{ + Align, Anchor, AutoFit, Bullet, Result, Session, Spacing, TextDir, Value, bad, bool_or, color_param, current_para, f64_or, f64_param, + format_body, format_para, json, ok, on_param, resolve, str_param, +}; + +pub(super) fn align_to(s: &mut Session, a: Align) -> Result { + format_para(s, &move |p| p.props.align = Some(a)) +} + +pub(super) 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, + "justifyLow" | "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => Align::Left, + }; + align_to(s, a) +} + +/// Paragraph reading direction. Switching it mirrors left and right alignment, so text that sat +/// at the start edge stays there. +pub(super) fn rtl(s: &mut Session, p: &Value) -> Result { + let on = on_param(p, current_para(s).rtl); + format_para(s, &move |para| { + if para.props.rtl.unwrap_or(false) != on { + para.props.align = match para.props.align { + None | Some(Align::Left) => Some(Align::Right), + Some(Align::Right) => Some(Align::Left), + other => other, + }; + } + para.props.rtl = Some(on); + }) +} + +pub(super) fn bullets(s: &mut Session, p: &Value) -> Result { + let cur = matches!(current_para(s).bullet, Some(Bullet::Char { .. })); + let on = on_param(p, Some(cur)); + let ch = str_param(p, "char").unwrap_or("•").to_string(); + let col = color_param(p, "color"); + let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); + let explicit_char = p.get("char").is_some(); + format_para(s, &move |para| { + if on { + para.props.bullet = Some(Bullet::Char { char: ch.clone() }); + para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() }; + if col.is_some() { + para.props.bullet_color = col.clone(); + } + if size.is_some() { + para.props.bullet_size = size; + } + if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) { + para.props.margin_left = Some(18.0 + 36.0 * para.level as f64); + para.props.indent = Some(-18.0); + } + } else { + para.props.bullet = Some(Bullet::None); + para.props.indent = Some(0.0); + para.props.margin_left = Some(36.0 * para.level as f64); + } + }) +} + +pub(super) fn numbering(s: &mut Session, p: &Value) -> Result { + let cur = matches!(current_para(s).bullet, Some(Bullet::AutoNum { .. })); + let on = on_param(p, Some(cur)); + let scheme = str_param(p, "scheme").unwrap_or("arabicPeriod").to_string(); + let start = p.get("start").and_then(Value::as_u64).map(|v| v.clamp(1, 32767) as u32).unwrap_or(1); + let col = color_param(p, "color"); + let size = f64_param(p, "size").map(|v| (v / 100.0).clamp(0.25, 4.0)); + format_para(s, &move |para| { + if on { + para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start }); + if col.is_some() { + para.props.bullet_color = col.clone(); + } + if size.is_some() { + para.props.bullet_size = size; + } + para.props.margin_left = Some(27.0 + 36.0 * para.level as f64); + para.props.indent = Some(-27.0); + } else { + para.props.bullet = Some(Bullet::None); + para.props.indent = Some(0.0); + para.props.margin_left = Some(36.0 * para.level as f64); + } + }) +} + +pub(super) fn level(s: &mut Session, d: i32) -> Result { + format_para(s, &move |p| { + let l = (p.level as i32 + d).clamp(0, 8) as u8; + // Explicit indents shift with the level. + if l != p.level { + if let Some(m) = p.props.margin_left.as_mut() { + *m = (*m + 36.0 * d as f64).max(0.0); + } + p.level = l; + } + }) +} + +pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result { + let sp = + if let Some(pt) = f64_param(p, "pt") { Spacing::Pts(pt.clamp(0.0, 1584.0)) } else { Spacing::Pct(f64_or(p, "lines", 1.0).clamp(0.0, 9.99)) }; + format_para(s, &move |para| para.props.line_spacing = Some(sp)) +} + +pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result { + let a = str_param(p, "align").map(|a| match a { + "center" => Align::Center, + "right" => Align::Right, + "justify" => Align::Justify, + "distributed" => Align::Distributed, + _ => Align::Left, + }); + let ml = f64_param(p, "indentLeft"); + let ind = f64_param(p, "indentFirst"); + let sb = f64_param(p, "spaceBefore"); + let sa = f64_param(p, "spaceAfter"); + let ls = f64_param(p, "lineSpacing"); + let lsp = f64_param(p, "lineSpacingPt"); + format_para(s, &move |para| { + if a.is_some() { + para.props.align = a; + } + if ml.is_some() { + para.props.margin_left = ml.map(|v| v.max(0.0)); + } + if ind.is_some() { + para.props.indent = ind; + } + if let Some(v) = sb { + para.props.space_before = Some(Spacing::Pts(v.max(0.0))); + } + if let Some(v) = sa { + para.props.space_after = Some(Spacing::Pts(v.max(0.0))); + } + if let Some(v) = ls { + para.props.line_spacing = Some(Spacing::Pct(v.clamp(0.0, 9.99))); + } + if let Some(v) = lsp { + para.props.line_spacing = Some(Spacing::Pts(v.max(0.0))); + } + }) +} + +const PARA_DIALOG_KEYS: &[&str] = + &["align", "indentLeft", "indentRight", "indentFirst", "special", "specialBy", "spaceBefore", "spaceAfter", "lineSpacing", "lineSpacingPt"]; + +fn para_dialog_has_fields(p: &Value) -> bool { + PARA_DIALOG_KEYS.iter().any(|k| p.get(*k).is_some()) +} + +pub(super) fn finite_key(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { + None => Ok(None), + Some(v) => { + let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number")))?; + Ok(Some(n)) + } + } +} + +pub(super) fn clamp_pt(v: f64) -> f64 { + v.clamp(0.0, 1584.0) +} + +fn parse_align(cmd: &str, s: &str) -> Result { + Ok(match s { + "left" | "l" => Align::Left, + "center" | "ctr" => Align::Center, + "right" | "r" => Align::Right, + "justify" | "just" => Align::Justify, + "justLow" => Align::JustLow, + "distributed" | "dist" => Align::Distributed, + _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")), + }) +} + +fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::JustLow => "justLow", + Align::Distributed => "distributed", + } +} + +fn spacing_parts(s: Option) -> (&'static str, f64) { + match s { + Some(Spacing::Pct(v)) => ("pct", v), + Some(Spacing::Pts(v)) => ("pts", v), + None => ("pts", 0.0), + } +} + +fn paragraph_dialog_params(s: &Session) -> Value { + let pp = current_para(s); + let indent = pp.indent.unwrap_or(0.0); + let (special, by) = if indent > 0.05 { + ("first", indent) + } else if indent < -0.05 { + ("hanging", -indent) + } else { + ("none", 0.0) + }; + let (line_kind, line_value) = match pp.line_spacing { + Some(Spacing::Pts(v)) => ("exactly", v), + Some(Spacing::Pct(v)) => ("multiple", v), + None => ("multiple", 1.0), + }; + let (before_kind, before) = spacing_parts(pp.space_before); + let (after_kind, after) = spacing_parts(pp.space_after); + let tabs: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({ + "align": align_name(pp.align.unwrap_or(Align::Left)), + "indentLeft": pp.margin_left.unwrap_or(0.0), + "indentRight": pp.margin_right.unwrap_or(0.0), + "special": special, + "specialBy": by, + "spaceBefore": before, + "spaceBeforeKind": before_kind, + "spaceAfter": after, + "spaceAfterKind": after_kind, + "lineKind": line_kind, + "lineValue": line_value, + "tabs": tabs, + "defaultTab": pp.default_tab.unwrap_or(72.0), + }) +} + +fn write_spacing(kind: Option<&str>, value: f64) -> Spacing { + if kind == Some("pct") { Spacing::Pct(value.clamp(0.0, 10.0)) } else { Spacing::Pts(clamp_pt(value)) } +} + +pub(super) fn paragraph_dialog(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.paragraphDialog"; + if !para_dialog_has_fields(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "paragraph".into(), params: paragraph_dialog_params(s) }); + return ok(); + } + let align = match p.get("align") { + None => None, + Some(Value::String(a)) => Some(parse_align(CMD, a)?), + Some(_) => return Err(bad(CMD, "`align` must be a string")), + }; + let indent_left = finite_key(p, CMD, "indentLeft")?.map(clamp_pt); + let indent_right = finite_key(p, CMD, "indentRight")?.map(clamp_pt); + let special_by = finite_key(p, CMD, "specialBy")?.map(|v| clamp_pt(v.max(0.0))); + let indent = match p.get("special") { + None => finite_key(p, CMD, "indentFirst")?.map(|v| v.clamp(-1584.0, 1584.0)), + Some(Value::String(kind)) => { + let by = special_by.unwrap_or(0.0); + Some(match kind.as_str() { + "none" => 0.0, + "first" | "firstLine" => by, + "hanging" | "hang" => -by, + _ => return Err(bad(CMD, "`special` must be none, first or hanging")), + }) + } + Some(_) => return Err(bad(CMD, "`special` must be none, first or hanging")), + }; + let space_before = finite_key(p, CMD, "spaceBefore")?; + let space_after = finite_key(p, CMD, "spaceAfter")?; + let before_kind = str_param(p, "spaceBeforeKind"); + let after_kind = str_param(p, "spaceAfterKind"); + for kind in [before_kind, after_kind].into_iter().flatten() { + if !matches!(kind, "pts" | "pct") { + return Err(bad(CMD, "spacing kind must be pts or pct")); + } + } + let line_mult = finite_key(p, CMD, "lineSpacing")?.map(|v| v.clamp(0.0, 9.99)); + let line_pt = finite_key(p, CMD, "lineSpacingPt")?.map(clamp_pt); + format_para(s, &move |para| { + if align.is_some() { + para.props.align = align; + } + if indent_left.is_some() { + para.props.margin_left = indent_left; + } + if indent_right.is_some() { + para.props.margin_right = indent_right; + } + if indent.is_some() { + para.props.indent = indent; + } + if let Some(v) = space_before { + para.props.space_before = Some(write_spacing(before_kind, v)); + } + if let Some(v) = space_after { + para.props.space_after = Some(write_spacing(after_kind, v)); + } + if let Some(v) = line_mult { + para.props.line_spacing = Some(Spacing::Pct(v)); + } + if let Some(v) = line_pt { + para.props.line_spacing = Some(Spacing::Pts(v)); + } + }) +} + +pub(super) fn tab_align(s: &str) -> Option<&'static str> { + Some(match s { + "l" | "left" => "l", + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => return None, + }) +} + +pub(super) fn tabs_params(s: &Session) -> Value { + let pp = current_para(s); + let stops: Vec = pp.tabs.unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + json!({"tabs": stops, "defaultTab": pp.default_tab.unwrap_or(72.0)}) +} + +pub(super) fn anchor(s: &mut Session, p: &Value) -> Result { + let a = match str_param(p, "anchor").unwrap_or("top") { + "middle" | "center" | "ctr" => Anchor::Middle, + "bottom" | "b" => Anchor::Bottom, + _ => Anchor::Top, + }; + format_body(s, &move |b| b.anchor = Some(a)) +} + +pub(super) fn direction(s: &mut Session, p: &Value) -> Result { + let d = match str_param(p, "dir").unwrap_or("horizontal") { + "rotate90" | "vert" => TextDir::Vertical, + "rotate270" | "vert270" => TextDir::Vertical270, + "stacked" => TextDir::Stacked, + _ => TextDir::Horizontal, + }; + format_body(s, &move |b| b.vert = Some(d)) +} + +/// The edited or first selected shape's columns as drawn: the count and the spacing in points. +fn current_columns(s: &Session) -> (u32, f64) { + let Ok(st) = s.doc() else { return (1, 0.0) }; + let id = match &st.selection.text { + Some(t) if !t.notes => Some(t.shape), + _ => st.selection.shapes.first().copied(), + }; + let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) }; + let body = match crate::cmd::text::ctx_for(&st.doc, &st.selection) { + Some(c) => resolve::body(&c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + (body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0)) +} + +pub(super) fn columns(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.columns"; + let count = match p.get("count") { + None => None, + Some(v) => { + let n = v + .as_f64() + .filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n)) + .ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?; + Some(n as u32) + } + }; + let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt); + if count.is_none() && spacing.is_none() { + let (count, spacing) = current_columns(s); + s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) }); + return ok(); + } + format_body(s, &move |b| { + if let Some(n) = count { + b.columns = Some(n); + } + if let Some(sp) = spacing { + b.col_spacing = Some(sp); + } + }) +} + +pub(super) fn autofit(s: &mut Session, p: &Value) -> Result { + let m = match str_param(p, "mode").unwrap_or("none") { + "shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }, + "resize" | "shape" => AutoFit::Shape, + _ => AutoFit::None, + }; + format_body(s, &move |b| b.autofit = Some(m)) +} + +pub(super) fn wrap(s: &mut Session, p: &Value) -> Result { + let on = bool_or(p, "on", true); + format_body(s, &move |b| b.wrap = Some(on)) +} + +pub(super) fn margins(s: &mut Session, p: &Value) -> Result { + 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)); + } + }) +} diff --git a/crates/engine/src/cmd/format/ruler.rs b/crates/engine/src/cmd/format/ruler.rs new file mode 100644 index 000000000..2034c0faf --- /dev/null +++ b/crates/engine/src/cmd/format/ruler.rs @@ -0,0 +1,116 @@ +use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params}; +use super::{Result, Session, TabStop, TextDir, Value, bad, bool_or, current_para, format_para, json, ok, resolve}; + +/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops, +/// and where its text area sits on the slide. +pub(super) fn ruler(s: &mut Session, _p: &Value) -> Result { + let props = current_para(s); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("format.ruler", "not editing text"))?; + let (x, y, w, rotated) = match (t.notes, st.shape(t.shape)) { + (false, Some(sh)) => { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection); + let x = match &ctx { + Some(c) => resolve::xfrm(c, sh), + None => sh.xfrm.unwrap_or_default(), + }; + let body = match &ctx { + Some(c) => resolve::body(c, sh), + None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(), + }; + let (l, r, tp) = (body.inset_l.unwrap_or(7.2), body.inset_r.unwrap_or(7.2), body.inset_t.unwrap_or(3.6)); + (x.x + l, x.y + tp, (x.w - l - r).max(0.0), x.rot != 0.0 || body.vert.is_some_and(|v| v != TextDir::Horizontal)) + } + _ => (0.0, 0.0, 0.0, true), + }; + let tabs: Vec = props.tabs.clone().unwrap_or_default().iter().map(|t| json!({"pos": t.pos, "align": t.align})).collect(); + Ok(json!({ + "x": x, "y": y, "width": w, "rotated": rotated, + "marginLeft": props.margin_left.unwrap_or(0.0), + "indent": props.indent.unwrap_or(0.0), + "tabs": tabs, + "defaultTab": props.default_tab.unwrap_or(72.0), + })) +} + +const MAX_TABS: usize = 32; + +pub(super) fn tabs(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "format.tabs"; + let fin = |v: Option| v.filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 4000.0)); + let align_of = |v: &Value| match v.get("align").and_then(Value::as_str).unwrap_or("l") { + "ctr" | "center" => "ctr", + "r" | "right" => "r", + "dec" | "decimal" => "dec", + _ => "l", + }; + let add = p.get("add").and_then(|a| fin(a.get("pos").and_then(Value::as_f64)).map(|pos| (pos, align_of(a).to_string()))); + let mv = p.get("move").and_then(|m| Some((fin(m.get("from").and_then(Value::as_f64))?, fin(m.get("to").and_then(Value::as_f64))?))); + let remove = fin(p.get("remove").and_then(Value::as_f64)); + // `set` replaces every stop: entries without a usable position are dropped, an unknown + // alignment or a non-object entry is an error; stops within 0.05 pt collapse (the last wins). + let set = match p.get("set") { + None => None, + Some(Value::Array(items)) => { + if items.len() > 64 { + return Err(bad(CMD, "`set` has more than 64 tab stops")); + } + let mut out: Vec = Vec::new(); + for (i, item) in items.iter().enumerate() { + let Some(obj) = item.as_object() else { + return Err(bad(CMD, format!("tab stop {i} must be an object"))); + }; + let Some(pos) = obj.get("pos").and_then(Value::as_f64).filter(|n| n.is_finite()).map(clamp_pt) else { + continue; + }; + let align = match obj.get("align").and_then(Value::as_str) { + None => "l", + Some(a) => tab_align(a).ok_or_else(|| bad(CMD, format!("tab stop {i} align must be l, ctr, r or dec")))?, + }; + if let Some(prev) = out.iter_mut().find(|t| (t.pos - pos).abs() < 0.05) { + *prev = TabStop { pos, align: align.to_string() }; + } else { + out.push(TabStop { pos, align: align.to_string() }); + } + } + Some(out) + } + Some(_) => return Err(bad(CMD, "`set` must be an array")), + }; + let clear = bool_or(p, "clear", false); + let default_tab = finite_key(p, CMD, "defaultTab")?.map(|v| v.clamp(1.0, 1584.0)); + let edits_stops = set.is_some() || add.is_some() || mv.is_some() || remove.is_some() || clear; + if !edits_stops && default_tab.is_none() { + // No fields: open the Tabs dialog on the caret's paragraph. + s.ui_requests.push(crate::UiRequest::Dialog { id: "tabs".into(), params: tabs_params(s) }); + return ok(); + } + // The stops as the caret's paragraph shows them (inherited ones included), so editing a + // placeholder's tab that came from its layout works like editing one set on the paragraph. + let base = current_para(s).tabs.unwrap_or_default(); + let near = |a: f64, b: f64| (a - b).abs() < 0.5; + let mut stops = if clear { vec![] } else { set.unwrap_or(base) }; + if let Some(at) = remove { + stops.retain(|t| !near(t.pos, at)); + } + if let Some((from, to)) = mv + && let Some(t) = stops.iter_mut().find(|t| near(t.pos, from)) + { + t.pos = to; + } + if let Some((pos, align)) = add { + stops.retain(|t| !near(t.pos, pos)); + stops.push(TabStop { pos, align }); + } + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + stops.dedup_by(|a, b| near(a.pos, b.pos)); + stops.truncate(MAX_TABS); + format_para(s, &move |para| { + if edits_stops { + para.props.tabs = Some(stops.clone()); + } + if let Some(d) = default_tab { + para.props.default_tab = Some(d); + } + }) +} diff --git a/crates/engine/src/cmd/format/text_effects.rs b/crates/engine/src/cmd/format/text_effects.rs new file mode 100644 index 000000000..82a4a85a6 --- /dev/null +++ b/crates/engine/src/cmd/format/text_effects.rs @@ -0,0 +1,139 @@ +use super::{ + Line, Result, Session, Value, bad, color_param, current_run, ed, edit_shapes, f64_or, format_body, format_run, json, ok, on_param, str_param, +}; + +pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result { + let v = on_param(p, Some(current_run(s).shadow.is_some())); + let sh = deckcraft_model::style::Shadow { + color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK).with(deckcraft_color::ColorTransform::Alpha(43000)), + blur: 3.0, + dist: 2.0, + dir: 45.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + }; + format_run(s, &move |r| r.shadow = if v { Some(sh.clone()) } else { None }) +} + +pub(super) fn text_outline(s: &mut Session, p: &Value) -> Result { + let c = color_param(p, "color"); + let w = f64_or(p, "width", 0.75); + format_run(s, &move |r| r.outline = c.clone().map(|c| Line::solid(c, w))) +} + +pub(super) fn text_warp(s: &mut Session, p: &Value) -> Result { + let preset = str_param(p, "preset").ok_or_else(|| bad("format.textWarp", "missing `preset`"))?; + let clear = preset == "none" || preset == "textNoShape"; + if !clear && !deckcraft_geom::warp::is_supported(preset) { + return Err(bad("format.textWarp", format!("unknown text transform `{preset}`"))); + } + let adj = match p.get("adj") { + None => Vec::new(), + Some(Value::Array(arr)) => { + if arr.len() > 8 { + return Err(bad("format.textWarp", "at most 8 adjust values")); + } + let mut v = Vec::with_capacity(arr.len()); + for a in arr { + let Some(n) = a.as_f64().filter(|n| n.is_finite()) else { + return Err(bad("format.textWarp", "adjust values must be finite numbers")); + }; + v.push(n.clamp(-2.0e9, 2.0e9)); + } + v + } + Some(_) => return Err(bad("format.textWarp", "`adj` must be an array of numbers")), + }; + let stored = if clear { None } else { Some(preset.to_string()) }; + format_body(s, &|b| { + b.warp.clone_from(&stored); + b.warp_adj.clone_from(&adj); + }) +} + +pub(super) fn case(s: &mut Session, p: &Value) -> Result { + let mode = str_param(p, "mode").unwrap_or("cycle").to_string(); + let st = s.doc()?; + if let Some(t) = st.selection.text.clone() { + let (mut a, mut b) = t.ordered(); + let body = crate::cmd::text::body_of(st, &t).cloned().unwrap_or_default(); + let collapsed = a == b; + if collapsed { + // Shift+F3 with a bare caret changes the word it is in and puts the caret at its start. + let (wa, wb) = ed::word_at(&body, a); + let is_word = ed::text_range(&body, wa, wb).chars().any(char::is_alphanumeric); + if !(is_word && wa.1 <= a.1 && a.1 < wb.1) { + return ok(); + } + (a, b) = (wa, wb); + } + let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() }; + crate::cmd::text::edit_text(s, move |body, t| { + let last = body.paragraphs.len().saturating_sub(1); + for pi in a.0..=b.0.min(last) { + let Some(para) = body.paragraphs.get_mut(pi) else { continue }; + let n = para.char_len(); + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { n }; + let mut pos = 0; + let mut pieces = Vec::new(); + for (ri, r) in para.runs.iter().enumerate() { + // Breaks and fields are read for context (a break ends a word) but not rewritten. + let len = r.char_len(); + let rs = from.max(pos).saturating_sub(pos); + let re = to.min(pos + len).saturating_sub(pos); + if re > rs { + pieces.push((ri, rs, re)); + } + pos += len; + } + let mids: Vec = pieces + .iter() + .map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default()) + .collect(); + let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::>(), &m); + for (&(ri, rs, re), new) in pieces.iter().zip(changed) { + let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue }; + let pre: String = r.text.chars().take(rs).collect(); + let post: String = r.text.chars().skip(re).collect(); + r.text = format!("{pre}{new}{post}"); + } + } + if collapsed { + t.anchor = a; + t.caret = a; + } + })?; + return ok(); + } + let mode = if mode == "cycle" { + let all: String = + st.selection.shapes.iter().filter_map(|id| st.shape(*id)).filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::>().join( + " +", + ); + ed::next_case(&all).to_string() + } else { + mode + }; + edit_shapes(s, &json!({}), "format.case", |sh| { + if let Some(t) = sh.text.as_mut() { + for para in &mut t.paragraphs { + // Breaks and fields are read for context (a break ends a word) but not rewritten. + let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect(); + let changed = ed::change_case_parts(&texts, &mode); + for (r, new) in para.runs.iter_mut().zip(changed) { + if r.kind == deckcraft_model::text::RunKind::Text { + r.text = new; + } + } + } + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 95af342c1..92f44711a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -14,6 +14,21 @@ use super::{ }; use crate::{EngineError, Result, Session}; +mod album; +mod chart; +mod links; +mod picture; +mod text_fields; + +use album::{photo_album, photo_album_edit}; +pub use chart::chart_type; +use chart::{chart, chart_data, chart_options, chart_type_cmd}; +use links::{action_button, hyperlink, shape_action}; +pub use picture::content_type; +pub(crate) use picture::media_bytes; +use picture::{audio, picture, video}; +use text_fields::{date_time, slide_number, symbol, word_art}; + pub fn specs() -> Vec { vec![ cmd!( @@ -317,880 +332,6 @@ fn text_box(s: &mut Session, p: &Value) -> Result { 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)); - } - if let Some(path) = str_param(p, "path") { - let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); - return Ok((name, bytes)); - } - Err(bad(cmd, "missing `path` or `data`")) -} - -pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { - let l = name.to_ascii_lowercase(); - if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { - "image/png" - } else if bytes.starts_with(&[0xFF, 0xD8]) { - "image/jpeg" - } else if bytes.starts_with(b"GIF8") { - "image/gif" - } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - "image/webp" - } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { - "audio/wav" - } else if bytes.starts_with(b"BM") { - "image/bmp" - } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { - "image/tiff" - } else if bytes.starts_with(b"fLaC") { - "audio/flac" - } else if bytes.starts_with(b"OggS") { - if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } - } else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 { - // ADTS (layer bits 00): AAC. - "audio/aac" - } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { - "audio/mpeg" - } else if bytes.starts_with(b"FORM") { - "audio/aiff" - } else if bytes.get(4..8) == Some(b"ftyp") { - if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") { - "audio/mp4" - } else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") { - "video/quicktime" - } else { - "video/mp4" - } - } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { - if l.ends_with(".mka") || l.ends_with(".weba") { - "audio/webm" - } else if l.ends_with(".mkv") { - "video/x-matroska" - } else { - "video/webm" - } - } else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { - if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" } - } else if bytes.starts_with(b"caff") { - "audio/x-caf" - } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { - let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .ok() - .and_then(|r| r.into_dimensions().ok()) - .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) - .unwrap_or((288.0, 216.0)); - let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); - (w * k, h * k) -} - -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" && deckcraft_render::decode_image(&bytes).is_none() { - return Err(EngineError::Other(format!("{name}: the picture can't be read"))); - } - let (w, h) = fitted(s, &bytes); - // Into a selected empty picture/content placeholder, if there is one. - let target_ph = s.active().and_then(|d| { - d.selected_shapes() - .into_iter() - .find(|x| { - x.ph_type() - .is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body)) - && x.text.as_ref().is_none_or(|t| t.is_empty()) - }) - .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) - }); - let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { - Some((_, b)) => { - // Fill the placeholder, cropping the picture to its aspect. - b - } - None => default_rect(s, w, h), - }); - let crop = match (target_ph, rect_param(p, "rect")) { - (Some(_), None) => { - let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); - if ia > ba { - let c = (1.0 - ba / ia) / 2.0; - [c, 0.0, c, 0.0] - } else { - let c = (1.0 - ia / ba) / 2.0; - [0.0, c, 0.0, c] - } - } - _ => [0.0; 4], - }; - let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?; - let shape = Shape { - xfrm: Some(rect), - kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, - descr: name.clone(), - ..Default::default() - }; - if let Some((ph_id, _)) = target_ph { - // Replace the placeholder with the picture (it keeps the placeholder link). - let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); - s.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; - if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { - sh.kind = shape.kind.clone(); - sh.xfrm = Some(rect); - sh.ph = phv.map(|mut p| { - p.kind = deckcraft_model::PhType::Picture; - p - }); - sh.text = None; - sh.descr = name.clone(); - } - sel.shapes = vec![ph_id]; - Ok(()) - })?; - return Ok(json!({"id": ph_id})); - } - let id = add_shape(s, "insert.picture", shape, true)?; - Ok(json!({"id": id})) -} - -const ALBUM_MAX_ITEMS: usize = 48; -const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024; -const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024; -const ALBUM_MAX_TEXT: usize = 400; - -#[derive(Clone, Copy)] -struct AlbumLayout { - cols: usize, - rows: usize, - per: usize, - title: bool, - fit: bool, -} - -#[derive(Clone, Copy)] -enum AlbumFrame { - None, - Rect, - Rounded, - Simple, -} - -struct AlbumPic { - name: String, - bytes: Vec, - px_w: f64, - px_h: f64, - rot: f64, - brightness: f64, - contrast: f64, -} - -struct AlbumOpts { - layout_key: String, - frame_key: String, - layout: AlbumLayout, - frame: AlbumFrame, - captions: bool, - grayscale: bool, - theme: Option, -} - -enum AlbumItem { - Picture(AlbumPic), - Text(String), -} - -enum AlbumSlide { - Pictures(Vec), - Text(String), -} - -/// Insert a photo album after the current slide. -/// -/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one -/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit. -fn photo_album(s: &mut Session, p: &Value) -> Result { - if p.get("pictures").is_none_or(Value::is_null) { - s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) }); - return Ok(json!({"dialog": "photoAlbum"})); - } - let opts = album_opts(p)?; - let items = album_items(p)?; - commit_album(s, items, opts, None) -} - -fn photo_album_edit(s: &mut Session, p: &Value) -> Result { - if p.get("pictures").is_none_or(Value::is_null) { - let seed = album_seed(s)?; - let album = seed.get("album").cloned().unwrap_or(Value::Null); - s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed }); - return Ok(json!({"dialog": "photoAlbum", "album": album})); - } - let opts = album_opts(p)?; - let items = album_items(p)?; - let (id, ids) = album_target(s, p)?; - commit_album(s, items, opts, Some((id, ids))) -} - -fn commit_album(s: &mut Session, items: Vec, opts: AlbumOpts, replace: Option<(u32, Vec)>) -> Result { - let (snapshot, sel) = { - let st = s.doc()?; - (st.doc.clone(), st.selection.clone()) - }; - let result = (|| { - let album_id = match replace.as_ref().map(|(id, _)| *id) { - Some(id) if id != 0 => id, - _ => { - let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" }; - s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))? - } - }; - let at = if let Some((_, ids)) = &replace { - let st = s.doc()?; - let indices: Vec = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect(); - let Some(at) = indices.iter().copied().min() else { - return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); - }; - s.execute("slide.delete", &json!({"ids": ids}))?; - Some(at) - } else { - None - }; - let mut opts = opts; - if opts.theme.as_deref().unwrap_or("").is_empty() { - opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty()); - } - apply_theme_if_needed(s, opts.theme.as_deref())?; - insert_album(s, items, &opts, at, album_id) - })(); - if result.is_err() - && let Some(st) = s.active_mut() - { - st.doc = snapshot; - st.selection = sel; - } - result -} - -fn album_norm(s: &str) -> String { - s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() -} - -fn album_layout(p: &Value) -> Result { - let key = match p.get("layout") { - None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), - Some(Value::String(s)) => s.as_str(), - Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")), - }; - match album_norm(key).as_str() { - "fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }), - "1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), - "1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }), - "2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }), - "2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }), - "4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }), - "4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }), - _ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))), - } -} - -fn album_frame(p: &Value) -> Result { - let key = match p.get("frame") { - None => return Ok(AlbumFrame::None), - Some(Value::String(s)) => s.as_str(), - Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")), - }; - match album_norm(key).as_str() { - "none" => Ok(AlbumFrame::None), - "rect" | "rectangle" => Ok(AlbumFrame::Rect), - "rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded), - "simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple), - _ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))), - } -} - -fn album_captions(p: &Value) -> Result { - match p.get("captions") { - None => Ok(false), - Some(Value::Bool(v)) => Ok(*v), - Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")), - } -} - -fn album_bool(p: &Value, key: &str) -> Result { - match p.get(key) { - None | Some(Value::Null) => Ok(false), - Some(Value::Bool(v)) => Ok(*v), - Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))), - } -} - -fn layout_key_of(layout: AlbumLayout) -> String { - if layout.fit { - return "fit".into(); - } - let n = match layout.per { - 2 => "2", - 4 => "4", - _ => "1", - }; - if layout.title { format!("{n}title") } else { n.into() } -} - -fn frame_key_of(frame: AlbumFrame) -> &'static str { - match frame { - AlbumFrame::None => "none", - AlbumFrame::Rect => "rect", - AlbumFrame::Rounded => "rounded", - AlbumFrame::Simple => "simple", - } -} - -fn album_opts(p: &Value) -> Result { - let layout = album_layout(p)?; - let frame = album_frame(p)?; - let theme = match p.get("theme") { - None | Some(Value::Null) => None, - Some(Value::String(s)) if s.trim().is_empty() => None, - Some(Value::String(s)) => { - let name = s.trim(); - let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)); - let Some(t) = found else { - return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`"))); - }; - Some(t.name) - } - Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")), - }; - Ok(AlbumOpts { - layout_key: layout_key_of(layout), - frame_key: frame_key_of(frame).into(), - layout, - frame, - captions: album_captions(p)?, - grayscale: album_bool(p, "grayscale")?, - theme, - }) -} - -/// A finite number, or 0 when the key is absent. A present non-number is an error. -fn album_finite(item: &Value, key: &str) -> Result { - match item.get(key) { - None | Some(Value::Null) => Ok(0.0), - Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))), - } -} - -fn album_rot(item: &Value) -> Result { - let n = album_finite(item, "rot")?; - Ok(((n % 360.0) + 360.0) % 360.0) -} - -fn album_unit(item: &Value, key: &str) -> Result { - let n = album_finite(item, key)?; - if !(-1.0..=1.0).contains(&n) { - return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1"))); - } - Ok(n) -} - -fn album_items(p: &Value) -> Result> { - let Some(arr) = p.get("pictures").and_then(Value::as_array) else { - return Err(bad("insert.photoAlbum", "`pictures` must be a list")); - }; - if arr.is_empty() { - return Err(bad("insert.photoAlbum", "add at least one picture or text box")); - } - if arr.len() > ALBUM_MAX_ITEMS { - return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes")); - } - let mut items = Vec::with_capacity(arr.len()); - let mut total = 0usize; - for item in arr { - let Some(obj) = item.as_object() else { - return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box")); - }; - let has_media = obj.contains_key("data") || obj.contains_key("path"); - if !has_media { - let Some(text) = obj.get("text").and_then(Value::as_str) else { - return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`")); - }; - let text = text.trim(); - if text.is_empty() { - return Err(bad("insert.photoAlbum", "a text box needs some text")); - } - if text.chars().count() > ALBUM_MAX_TEXT { - return Err(bad("insert.photoAlbum", "text is longer than 400 characters")); - } - items.push(AlbumItem::Text(text.to_string())); - continue; - } - let (name, bytes) = media_bytes(item, "insert.photoAlbum")?; - if bytes.len() > ALBUM_MAX_BYTES { - return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB"))); - } - let next = total.saturating_add(bytes.len()); - if next > ALBUM_MAX_TOTAL { - return Err(bad("insert.photoAlbum", "the album is larger than 64 MB")); - } - total = next; - let ct = content_type(&name, &bytes); - if !ct.starts_with("image/") { - return Err(bad("insert.photoAlbum", format!("{name} is not a picture"))); - } - if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { - return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read"))); - } - let (px_w, px_h) = image_px(&bytes); - items.push(AlbumItem::Picture(AlbumPic { - name, - bytes, - px_w, - px_h, - rot: album_rot(item)?, - brightness: album_unit(item, "brightness")?, - contrast: album_unit(item, "contrast")?, - })); - } - Ok(items) -} - -fn image_px(bytes: &[u8]) -> (f64, f64) { - image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .ok() - .and_then(|r| r.into_dimensions().ok()) - .map(|(w, h)| (w as f64, h as f64)) - .filter(|(w, h)| *w > 0.0 && *h > 0.0) - .unwrap_or((4.0, 3.0)) -} - -fn file_stem(name: &str) -> String { - let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name); - let stem = match base.rsplit_once('.') { - Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s, - _ => base, - }; - if stem.is_empty() { "Picture".to_string() } else { stem.to_string() } -} - -fn album_plan(items: Vec, per: usize) -> Vec { - let per = per.max(1); - let mut out = Vec::new(); - let mut batch = Vec::new(); - for item in items { - match item { - AlbumItem::Text(text) => { - if !batch.is_empty() { - out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); - } - out.push(AlbumSlide::Text(text)); - } - AlbumItem::Picture(pic) => { - batch.push(pic); - if batch.len() >= per { - out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); - } - } - } - } - if !batch.is_empty() { - out.push(AlbumSlide::Pictures(batch)); - } - out -} - -struct Placed { - pic: [f64; 4], - caption: Option<[f64; 4]>, -} - -fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) { - let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 }; - let box_w = box_w.max(1.0); - let box_h = box_h.max(1.0); - if !aspect.is_finite() || aspect <= 0.0 { - return (box_w, box_h); - } - if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) } -} - -fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec) { - let margin = if layout.fit { 18.0 } else { 36.0 }; - let title_band = if layout.title { 48.0 } else { 0.0 }; - let gap = 12.0; - let cap_h = 22.0; - let cap_gap = 4.0; - let left = margin; - let top = margin + title_band; - let grid_w = (width - margin - left).max(1.0); - let grid_h = (height - margin - top).max(1.0); - let cols = layout.cols.max(1); - let rows = layout.rows.max(1); - let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0); - let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0); - let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h }; - let mut placed = Vec::with_capacity(n); - for i in 0..n { - let col = i % cols; - let row = i / cols; - if row >= rows { - break; - } - let cell_x = left + col as f64 * (cell_w + gap); - let cell_y = top + row as f64 * (cell_h + gap); - placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None }); - } - let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None }; - (title, placed) -} - -fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec) { - let (title, slots) = place_album(width, height, layout, pics.len(), captions); - let placed = pics - .iter() - .zip(slots) - .map(|(pic, slot)| { - let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h); - let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0; - let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0; - let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None }; - Placed { pic: [x, y, dw, dh], caption } - }) - .collect(); - (title, placed) -} - -fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> { - let params = match frame { - AlbumFrame::None => return Ok(()), - AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}), - AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}), - AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}), - }; - s.execute("shape.line", ¶ms)?; - Ok(()) -} - -struct AlbumMark { - id: u32, - layout: String, - frame: String, - captions: bool, - grayscale: bool, - theme: String, -} - -fn parse_album_mark(raw: &str) -> Option { - let rest = raw.strip_prefix("deckcraft-album:")?; - let (id_str, rest) = rest.split_once(';').unwrap_or((rest, "")); - let id = id_str.parse::().ok().filter(|id| *id != 0)?; - let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() }; - if !rest.is_empty() { - for part in rest.split(';') { - let Some((k, v)) = part.split_once('=') else { continue }; - match k { - "layout" => mark.layout = v.to_string(), - "frame" => mark.frame = v.to_string(), - "captions" => mark.captions = v == "1", - "gray" => mark.grayscale = v == "1", - "theme" => mark.theme = v.to_string(), - _ => {} - } - } - } - Some(mark) -} - -fn album_marker(id: u32, opts: &AlbumOpts) -> String { - let theme = opts.theme.as_deref().unwrap_or(""); - format!( - "deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}", - opts.layout_key, - opts.frame_key, - if opts.captions { 1 } else { 0 }, - if opts.grayscale { 1 } else { 0 }, - ) -} - -fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> { - let marker = album_marker(album_id, opts); - s.edit(|doc, sel| { - let Some(slide) = doc.slides.get_mut(sel.slide) else { - return Err(bad("insert.photoAlbum", "no slide to mark")); - }; - let slide = std::sync::Arc::make_mut(slide); - if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) { - slide.raw_ext = Some(marker); - } - slide.name = "Photo Album".into(); - Ok(()) - }) -} - -fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> { - let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) }; - let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default(); - if current.eq_ignore_ascii_case(name) { - return Ok(()); - } - s.execute("design.theme", &json!({"name": name}))?; - Ok(()) -} - -fn album_id_param(p: &Value) -> Option { - match p.get("album") { - Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0), - Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v != 0), - _ => None, - } -} - -fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec { - doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect() -} - -fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec { - if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") { - return Vec::new(); - } - let mut start = index; - while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") { - start -= 1; - } - let mut end = index; - while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") { - end += 1; - } - doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect() -} - -/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker). -fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec)> { - let st = s.doc()?; - if let Some(id) = album_id_param(p) { - let ids = slides_with_album(&st.doc, id); - if ids.is_empty() { - return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); - } - return Ok((id, ids)); - } - let Some(slide) = st.doc.slides.get(st.selection.slide) else { - return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); - }; - if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) { - let ids = slides_with_album(&st.doc, mark.id); - if ids.is_empty() { - return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); - } - return Ok((mark.id, ids)); - } - if slide.name == "Photo Album" { - let ids = album_name_run(&st.doc, st.selection.slide); - if !ids.is_empty() { - return Ok((0, ids)); - } - } - Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")) -} - -fn pic_count(slide: &deckcraft_model::Slide) -> usize { - slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count() -} - -fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) { - let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1); - let per = if max_pics >= 3 { - "4" - } else if max_pics == 2 { - "2" - } else { - "1" - }; - let title = - slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0))); - let layout = if title { format!("{per}title") } else { per.into() }; - let width = slides.iter().find_map(|s| { - s.shapes.iter().find_map(|sh| match &sh.kind { - ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width), - _ => None, - }) - }); - let frame = match width { - None => "none", - Some(w) if (w - 6.0).abs() < 0.2 => "simple", - Some(w) if (w - 2.25).abs() < 0.2 => "rounded", - Some(w) if w < 0.2 => "none", - Some(_) => "rect", - }; - let captions = slides.iter().any(|s| { - let bottoms: Vec = - s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect(); - !bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b))) - }); - let mut any = false; - let mut gray = true; - for s in slides { - for sh in &s.shapes { - if let ShapeKind::Picture { fill } = &sh.kind { - any = true; - gray &= fill.adjust.grayscale; - } - } - } - (layout, frame.into(), captions, any && gray, theme_now.to_string()) -} - -fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec { - let mut items = Vec::new(); - for slide in slides { - let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); - if pics.is_empty() { - let text = slide - .shapes - .iter() - .filter(|sh| sh.text_box) - .filter_map(|sh| sh.text.as_ref().map(|t| t.text())) - .find(|t| !t.trim().is_empty()) - .unwrap_or_default(); - if !text.trim().is_empty() { - items.push(json!({"text": text.trim()})); - } - continue; - } - for sh in pics { - let ShapeKind::Picture { fill } = &sh.kind else { continue }; - let media = doc.media(fill.media); - let name = if !sh.descr.is_empty() { - sh.descr.clone() - } else { - media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into()) - }; - items.push(json!({ - "name": name, - "data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(), - "rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0), - "brightness": fill.adjust.brightness, - "contrast": fill.adjust.contrast, - })); - } - } - items -} - -fn album_seed(s: &Session) -> Result { - let (id, ids) = album_target(s, &Value::Null)?; - let st = s.doc()?; - let slides: Vec<&deckcraft_model::Slide> = - ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect(); - let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into()); - let inferred = infer_album(&slides, &theme_now); - let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark)); - let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark { - ( - if mark.layout.is_empty() { inferred.0 } else { mark.layout }, - if mark.frame.is_empty() { inferred.1 } else { mark.frame }, - mark.captions, - mark.grayscale, - if mark.theme.is_empty() { theme_now } else { mark.theme }, - ) - } else { - inferred - }; - Ok(json!({ - "album": if id == 0 { Value::Null } else { json!(id) }, - "layout": layout, - "frame": frame, - "captions": captions, - "grayscale": grayscale, - "theme": theme, - "items": items_from_slides(&st.doc, &slides), - })) -} - -fn insert_album(s: &mut Session, items: Vec, opts: &AlbumOpts, at: Option, album_id: u32) -> Result { - let (width, height, first) = { - let st = s.doc()?; - let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) }); - (st.doc.slide_size.width, st.doc.slide_size.height, first) - }; - let layout = opts.layout; - let frame = opts.frame; - let captions = opts.captions; - let plan = album_plan(items, layout.per); - let mut slides = 0usize; - let mut pictures = 0usize; - let mut pin = at; - for slide in plan { - if let Some(index) = pin.take() { - s.execute("slide.new", &json!({"layout": "blank", "at": index}))?; - } else { - s.execute("slide.new", &json!({"layout": "blank"}))?; - } - stamp_album_slide(s, album_id, opts)?; - slides += 1; - match slide { - AlbumSlide::Text(text) => { - let rect = if layout.title { - [36.0, 18.0, (width - 72.0).max(36.0), 48.0] - } else { - [36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)] - }; - s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?; - } - AlbumSlide::Pictures(pics) => { - let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default(); - let (title, placed) = place_pictures(width, height, layout, &pics, captions); - for (pic, cell) in pics.iter().zip(placed) { - let data = base64_encode(&pic.bytes); - let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?; - let Some(id) = inserted.get("id").and_then(Value::as_u64) else { - return Err(bad("insert.photoAlbum", "the picture was not inserted")); - }; - apply_frame(s, frame, id)?; - if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 { - s.execute( - "picture.adjust", - &json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}), - )?; - } - if pic.rot > 0.01 { - s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?; - } - pictures += 1; - if let Some(cap) = cell.caption { - s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?; - } - } - if let Some(rect) = title { - s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?; - } - } - } - } - s.execute("slide.go", &json!({"index": first}))?; - Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id})) -} - fn table(s: &mut Session, p: &Value) -> Result { let rows = usize_param(p, "rows").unwrap_or(2).clamp(1, 75); let cols = usize_param(p, "cols").unwrap_or(2).clamp(1, 75); @@ -1212,666 +353,6 @@ fn table(s: &mut Session, p: &Value) -> Result { 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| deckcraft_model::chart::Series { - name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), - values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), - ..Default::default() - }) - .collect(); - } - if let Some(t) = p.get("title") { - c.title = t.as_str().map(String::from); - } - let size = s.doc()?.doc.slide_size; - let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); - let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; - Ok(json!({"id": id})) -} - -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); - let bytes = std::sync::Arc::new(bytes); - let info = deckcraft_media::probe(&bytes); - let has_video = info.as_ref().is_ok_and(|i| i.video.is_some()); - let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some()); - match &info { - Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))), - Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))), - // Not something we can read: embed it anyway when the type says it's media (it still - // round-trips through PPTX), otherwise refuse. - Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))), - Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))), - _ => {} - } - let (duration_ms, width, height, playable) = match &info { - Ok(i) => { - let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)); - (i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable()) - } - Err(_) => (0, 0, 0, false), - }; - let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); - let warning = if !playable { - Some(match &info { - Ok(i) => { - let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); - format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) - } - Err(e) => format!("{name} was inserted, but it can't be played ({e})"), - }) - } else if video && !decodable_video { - // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame - // can ever show — say so instead of leaving an unexplained black box (#1237). - info.as_ref() - .ok() - .and_then(|i| i.video.as_ref()) - .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) - } else { - None - }; - let poster_png = if video && decodable_video { super::media::poster_png(&bytes, 0) } else { None }; - let size = s.doc()?.doc.slide_size; - let rect = rect_param(p, "rect").unwrap_or_else(|| { - if video { - // Natural size (96 dpi), fitted inside 90 % of the slide and centred. - let (w, h) = - if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) }; - let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0); - let (w, h) = (w * k, h * k); - Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) - } else { - Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) - } - }); - let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); - let (media, poster) = s.edit(|doc, _| { - let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?; - let poster = match poster_png { - Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?), - None => None, - }; - Ok((media, poster)) - })?; - let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; - let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; - if let Some(w) = &warning { - s.ui_requests.push(crate::UiRequest::Message { text: w.clone() }); - } - let mut r = - json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning}); - if let Ok(i) = &info { - r["probe"] = super::media::probe_json(i); - } - Ok(r) -} - -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(deckcraft_model::text::Align::Center); - for r in &mut para.runs { - r.props.size = Some(54.0); - r.props.bold = Some(style % 2 == 1); - r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { - 0 => SchemeSlot::Tx1, - 1 => SchemeSlot::Accent1, - 2 => SchemeSlot::Accent2, - _ => SchemeSlot::Accent4, - }))); - if style % 3 == 2 { - r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); - } - } - } - } - let id = add_shape(s, "insert.wordArt", shape, true)?; - 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", "‹#›") -} - -const DATE_FORMATS: [&str; 13] = [ - "datetime1", - "datetime2", - "datetime3", - "datetime4", - "datetime5", - "datetime6", - "datetime7", - "datetime8", - "datetime9", - "datetime10", - "datetime11", - "datetime12", - "datetime13", -]; - -struct DateOpts { - format: String, - update: bool, - /// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language. - lang: Option, - sample: String, -} - -/// `true` when the call carries a format, update flag or language (apply it). Empty params open -/// the dialog instead. -fn date_time_apply(p: &Value) -> bool { - p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language")) -} - -fn canon_lang(raw: &str) -> Option<&'static str> { - match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { - "en" | "en-us" => Some("en-US"), - "en-gb" => Some("en-GB"), - "fr" | "fr-fr" => Some("fr-FR"), - "ja" | "ja-jp" => Some("ja-JP"), - "sv" | "sv-se" => Some("sv-SE"), - _ => None, - } -} - -fn date_opts(p: &Value) -> Result { - if p.get("format").is_some() { - let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f)); - if f.is_none() { - return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13")); - } - } - if p.get("update").is_some() && bool_param(p, "update").is_none() { - return Err(bad("insert.dateTime", "`update` must be true or false")); - } - let lang = if p.get("language").is_some() { - let raw = str_param(p, "language").filter(|l| !l.trim().is_empty()); - let Some(raw) = raw else { - return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE")); - }; - Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string()) - } else { - None - }; - let format = str_param(p, "format").unwrap_or("datetime1").to_string(); - let sample = date_sample(&format, lang.as_deref())?; - Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample }) -} - -/// The text `format` shows now in `lang`. Errors when that language has no names for the format -/// (month names and AM/PM are English only). An empty string where there is no clock (wasm). -fn date_sample(format: &str, lang: Option<&str>) -> Result { - use deckcraft_text::datetime::{DateTime, field_text}; - let probe = DateTime::new(2026, 10, 9, 20, 5, 7); - if field_text(format, lang, probe).is_none() { - return Err(bad("insert.dateTime", "this format isn't available in that language")); - } - Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default()) -} - -fn date_time(s: &mut Session, p: &Value) -> Result { - if !date_time_apply(p) { - s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) }); - return ok(); - } - let opts = date_opts(p)?; - let (doc, sel) = { - let st = s.doc()?; - (st.doc.clone(), st.selection.clone()) - }; - let r = apply_date(s, &opts); - if r.is_err() - && let Some(st) = s.active_mut() - { - st.doc = doc; - st.selection = sel; - } - r -} - -fn apply_date(s: &mut Session, o: &DateOpts) -> Result { - if s.doc()?.selection.text.is_some() { - if o.update { - super::text::insert_field(s, &o.format, &o.sample)?; - if let Some(lang) = &o.lang { - stamp_caret_lang(s, lang)?; - } - } else { - insert_plain(s, &o.sample, o.lang.as_deref())?; - } - return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); - } - // Not editing: the slide's date placeholder (this slide only). A layout with no date - // placeholder gets a text box instead, so the chosen format is still inserted. - s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; - if !write_date_placeholder(s, o)? { - return insert_date_box(s, o); - } - Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) -} - -/// Point the current slide's date placeholder at this format (a field, or the sample as plain text). -fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { - let format = o.format.clone(); - let sample = o.sample.clone(); - let lang = o.lang.clone(); - let update = o.update; - s.edit(|doc, sel| { - let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) }; - let sl = std::sync::Arc::make_mut(sl); - let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else { - return Ok(false); - }; - let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); - if let Some(lang) = lang { - props.lang = Some(lang); - } - let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; - let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() }; - let body = sh.text.clone().unwrap_or_default(); - sh.text = Some(TextBody { paragraphs: vec![para], ..body }); - Ok(true) - }) -} - -fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result { - let size = s.doc()?.doc.slide_size; - let y = (size.height - 48.0).max(0.0); - s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?; - if o.update { - super::text::insert_field(s, &o.format, &o.sample)?; - } else { - super::text::insert_text(s, &o.sample, None)?; - } - if let Some(lang) = &o.lang { - stamp_caret_lang(s, lang)?; - } - Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample})) -} - -/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge, -/// so the date stays its own run next to the surrounding text. -fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> { - let text = text.to_string(); - let lang = lang.map(str::to_string); - super::text::edit_text(s, move |body, t| { - let (a, b) = t.ordered(); - let at = deckcraft_model::edit::delete(body, a, b); - let mut props = deckcraft_model::edit::props_at(body, at); - if let Some(lang) = lang.clone() { - props.lang = Some(lang); - } - let c = deckcraft_model::edit::insert(body, at, &text, Some(props)); - t.anchor = c; - t.caret = c; - })?; - Ok(()) -} - -/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates. -fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> { - let lang = lang.to_string(); - super::text::edit_text(s, move |body, t| { - let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return }; - let mut at = 0usize; - for r in &mut p.runs { - let n = r.char_len(); - if n > 0 && at < t.caret.1 && t.caret.1 <= at + n { - r.props.lang = Some(lang); - return; - } - at += n; - } - })?; - Ok(()) -} - -fn symbol(s: &mut Session, p: &Value) -> Result { - 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 deckcraft_model::text::{Action, Hyperlink}; - // `display`: what a caret with nothing to link inserts, the address or the slide's title. - let (action, display) = if let Some(u) = str_param(p, "url") { - (Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string()) - } else if let Some(i) = usize_param(p, "slide") { - let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; - let title = slide.title(); - (Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title }) - } else { - return Err(bad("insert.hyperlink", "missing `url` or `slide`")); - }; - let display = str_param(p, "text").map_or(display, str::to_string); - let link = Hyperlink { - action, - tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), - highlight_click: bool_or(p, "highlightClick", false), - sound: None, - stop_sound: false, - zoom_return: None, - }; - if s.doc()?.selection.text.is_some() { - return super::text::link_text(s, link, &display); - } - edit_shapes(s, p, "insert.hyperlink", |sh| { - sh.click = Some(link.clone()); - Ok(()) - }) -} - -/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does -/// in a show. -fn shape_action(s: &mut Session, p: &Value) -> Result { - use deckcraft_model::text::{Action, Hyperlink}; - const CMD: &str = "shape.action"; - let hover = match str_param(p, "on").unwrap_or("click") { - "click" => false, - "hover" | "over" => true, - o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))), - }; - let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`"))); - let action = match str_param(p, "action").unwrap_or("none") { - "none" => None, - "next" => Some(Action::NextSlide), - "previous" => Some(Action::PreviousSlide), - "first" => Some(Action::FirstSlide), - "last" => Some(Action::LastSlide), - "lastViewed" => Some(Action::LastViewed), - "endShow" => Some(Action::EndShow), - "playMedia" => Some(Action::PlayMedia), - "url" => Some(Action::Url { url: need("url")? }), - "customShow" => { - let name = need("show")?; - if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { - return Err(bad(CMD, format!("no custom show `{name}`"))); - } - Some(Action::CustomShow { name, returns: true }) - } - "program" => Some(Action::Program { path: need("path")? }), - "slide" => { - let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?; - let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?; - Some(Action::Slide { slide: slide.id }) - } - a => return Err(bad(CMD, format!("unknown action `{a}`"))), - }; - let highlight = bool_param(p, "highlight").unwrap_or(true); - let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); - let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None }); - edit_shapes(s, p, CMD, |sh| { - if hover { - sh.hover = link.clone(); - } else { - sh.click = link.clone(); - } - Ok(()) - }) -} - -fn action_button(s: &mut Session, p: &Value) -> Result { - use deckcraft_model::text::{Action, Hyperlink}; - let kind = str_param(p, "kind").unwrap_or("custom"); - let (preset, action) = match kind { - "back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)), - "forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)), - "beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)), - "end" => ("actionButtonEnd", Some(Action::LastSlide)), - "home" => ("actionButtonHome", Some(Action::FirstSlide)), - "information" => ("actionButtonInformation", None), - "return" => ("actionButtonReturn", Some(Action::LastViewed)), - "movie" => ("actionButtonMovie", None), - "document" => ("actionButtonDocument", None), - "sound" => ("actionButtonSound", None), - "help" => ("actionButtonHelp", None), - _ => ("actionButtonBlank", None), - }; - let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0)); - let shape = Shape { - xfrm: Some(rect), - geom: Geom::preset(preset), - style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), - click: action.map(|a| Hyperlink { - action: a, - tooltip: String::new(), - highlight_click: true, - sound: None, - stop_sound: false, - zoom_return: None, - }), - ..Default::default() - }; - let id = add_shape(s, "insert.actionButton", shape, true)?; - Ok(json!({"id": id})) -} - -#[allow(dead_code)] -fn _kinds() -> RunKind { - RunKind::Text -} - -fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { - edit_shapes(s, p, cmd, |sh| match &mut sh.kind { - ShapeKind::Chart(c) => f(c), - _ => Err(bad(cmd, "not a chart")), - }) -} - -fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { - let k = chart_type(str_param(p, "type").unwrap_or("column")); - with_chart(s, p, "chart.type", |c| { - c.kind = k; - c.vary_colors = - matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) - || c.series.len() == 1 && c.vary_colors; - if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { - c.gridlines = true; - } - c.raw = None; - Ok(()) - }) -} - -fn chart_data(s: &mut Session, p: &Value) -> Result { - let cats: Option> = p - .get("categories") - .and_then(Value::as_array) - .map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect()); - let series: Option> = p.get("series").and_then(Value::as_array).map(|a| { - a.iter() - .map(|sv| deckcraft_model::chart::Series { - name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), - values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(), - ..Default::default() - }) - .collect() - }); - let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { - a.iter() - .take(deckcraft_model::chart::MAX_LEVELS) - .map(|l| { - l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() - }) - .collect() - }); - with_chart(s, p, "chart.data", |c| { - if let Some(cs) = &cats { - c.categories = cs.clone(); - } - if let Some(l) = &levels { - c.category_levels = l.clone(); - } - if let Some(se) = &series { - // Keep series formatting by position. - let old = std::mem::take(&mut c.series); - c.series = se - .iter() - .enumerate() - .map(|(i, n)| { - let mut x = old.get(i).cloned().unwrap_or_default(); - x.name = n.name.clone(); - x.values = n.values.clone(); - x - }) - .collect(); - } - c.raw = None; - Ok(()) - }) -} - -fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { - if let Some(n) = bin_count { - c.bin_count = n; - if n.is_some() { - c.bin_size = None; - } - } - if let Some(w) = bin_size { - c.bin_size = w; - if w.is_some() { - c.bin_count = None; - } - } -} - -fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { - if let Some(t) = subtotals { - let n = c.series.first().map_or(0, |s| s.values.len()); - let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); - t.sort_unstable(); - t.dedup(); - c.subtotals = t; - } - if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { - c.up_down_bars = b; - c.raw = None; - } - if let Some(q) = p.get("quartiles").and_then(Value::as_str) { - c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); - c.raw = None; - } -} - -fn chart_options(s: &mut Session, p: &Value) -> Result { - // Histogram bins: a count or a width (setting one clears the other; null for automatic). - let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); - let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); - if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { - return Err(bad("chart.options", "binSize must be a positive number")); - } - // Waterfall total points. - let subtotals: Option> = - p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); - // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's - // default (tx1 at lumMod 65 % / lumOff 35 %). - let text_color = match p.get("textColor") { - None => None, - Some(Value::Null) => Some(None), - Some(v) => { - Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) - } - }; - with_chart(s, p, "chart.options", |c| { - if let Some(t) = &text_color { - c.text_color = t.clone(); - } - apply_bin_options(c, bin_count, bin_size); - apply_subtotals_and_bars(c, p, subtotals.as_deref()); - if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { - c.raw = None; - } - if let Some(t) = p.get("title") { - c.title = t.as_str().map(String::from); - } - if let Some(l) = p.get("legend") { - c.legend = l.as_str().map(String::from); - } - if let Some(v) = bool_param(p, "dataLabels") { - c.data_labels = v; - // A series' own setting would otherwise keep overriding the chart-wide switch. - for series in &mut c.series { - series.data_labels = None; - } - } - if let Some(v) = bool_param(p, "gridlines") { - c.gridlines = v; - } - if let Some(v) = str_param(p, "palette") { - c.palette = v.to_string(); - } - c.raw = None; - Ok(()) - }) -} - /// A SmartArt graphic: a group regenerated from its outline (see [`super::smartart`]). Items with /// leading tabs are sub-points; `nodes` (`[{text, level}]`) may be given instead. fn smart_art(s: &mut Session, p: &Value) -> Result { @@ -2020,289 +501,4 @@ mod tests { } #[cfg(test)] -mod icon_and_picture_tests { - use deckcraft_model::ShapeKind; - use deckcraft_model::style::LineJoin; - - use super::*; - - /// 8×8 red PNG. - fn png() -> Vec { - vec![ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, - 68, 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, - 66, 96, 130, - ] - } - - fn pic(name: &str) -> Value { - json!({"name": name, "data": base64_encode(&png())}) - } - - fn n_slides(s: &Session) -> usize { - s.active().map(|d| d.doc.slides.len()).unwrap_or(0) - } - - fn pictures(s: &Session, index: usize) -> Vec { - s.active() - .and_then(|d| d.doc.slides.get(index)) - .map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect()) - .unwrap_or_default() - } - - fn box_text(s: &Session, index: usize) -> Vec { - s.active() - .and_then(|d| d.doc.slides.get(index)) - .map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect()) - .unwrap_or_default() - } - - #[test] - fn two_pictures_one_per_slide_are_one_undo() { - let mut s = Session::with_new(); - let before = n_slides(&s); - let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album"); - assert_eq!(r["slides"], 2); - assert_eq!(n_slides(&s), before + 2); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(pictures(&s, 2).len(), 1); - assert_eq!(s.active().unwrap().selection.slide, 1); - let undo = &s.active().unwrap().history.undo; - assert_eq!(undo.len(), 1); - assert_eq!(undo[0].label, "Photo Album…"); - s.execute("edit.undo", &json!({})).expect("undo"); - assert_eq!(n_slides(&s), before); - assert!(pictures(&s, 1).is_empty()); - } - - #[test] - fn two_up_shares_one_slide() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album"); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 2); - assert!(box_text(&s, 1).is_empty()); - } - - #[test] - fn captions_and_titles_use_the_file_stem() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions"); - assert_eq!(box_text(&s, 1), ["harbor"]); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title"); - assert_eq!(box_text(&s, 1), ["harbor"]); - assert_eq!(pictures(&s, 1).len(), 1); - let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y; - assert!(pic_y > 40.0, "the picture sits below the title"); - } - - #[test] - fn frames_set_the_picture_line() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect"); - let line = pictures(&s, 1)[0].line.clone().expect("line"); - assert_eq!(line.width, Some(1.5)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded"); - let line = pictures(&s, 1)[0].line.clone().expect("line"); - assert_eq!(line.width, Some(2.25)); - assert_eq!(line.join, Some(LineJoin::Round)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple"); - assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0)); - } - - #[test] - fn a_text_box_breaks_the_picture_row() { - let mut s = Session::with_new(); - let r = s - .execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"})) - .expect("album"); - assert_eq!(r["slides"], 3); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(box_text(&s, 2), ["A day on the water"]); - assert!(pictures(&s, 2).is_empty()); - assert_eq!(pictures(&s, 3).len(), 1); - assert_eq!(s.active().unwrap().history.undo.len(), 1); - } - - #[test] - fn no_pictures_opens_the_dialog_without_editing() { - let mut s = Session::with_new(); - let before = n_slides(&s); - let r = s.execute("insert.photoAlbum", &json!({})).expect("open"); - assert_eq!(r["dialog"], "photoAlbum"); - assert_eq!(n_slides(&s), before); - assert!(s.active().unwrap().history.undo.is_empty()); - assert_eq!(s.ui_requests.len(), 1); - match &s.ui_requests[0] { - crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"), - other => panic!("dialog request, got {other:?}"), - } - } - - #[test] - fn bad_input_adds_no_slides() { - let mut s = Session::with_new(); - let before = n_slides(&s); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err()); - let mut many = Vec::new(); - for i in 0..49 { - many.push(json!({"text": format!("box {i}")})); - } - assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err()); - assert_eq!(n_slides(&s), before); - assert!(s.active().unwrap().history.undo.is_empty()); - } - - #[test] - fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap(); - let one = pictures(&s, 1)[0].xfrm.unwrap().h; - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap(); - let fitted = pictures(&s, 1)[0].xfrm.unwrap().h; - assert!(fitted > one, "fit {fitted} should be taller than one-up {one}"); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap(); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 4); - } - - fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust { - match &pictures(s, index)[0].kind { - ShapeKind::Picture { fill } => fill.adjust.clone(), - _ => panic!("picture"), - } - } - - fn theme_name(s: &Session) -> String { - s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default() - } - - #[test] - fn edit_without_pictures_reopens_the_dialog() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); - s.ui_requests.clear(); - let before = n_slides(&s); - let undos = s.active().unwrap().history.undo.len(); - let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen"); - assert_eq!(r["dialog"], "photoAlbum"); - assert_eq!(n_slides(&s), before); - assert_eq!(s.active().unwrap().history.undo.len(), undos); - let params = match &s.ui_requests[0] { - crate::UiRequest::Dialog { id, params } => { - assert_eq!(id, "photoAlbum"); - params - } - other => panic!("dialog request, got {other:?}"), - }; - assert_eq!(params["items"][0]["name"], "hill.png"); - assert_eq!(params["items"][1]["name"], "lake.png"); - assert_eq!(params["layout"], "1"); - assert!(params["album"].as_u64().unwrap_or(0) > 0); - } - - #[test] - fn edit_reflows_the_album_in_one_undo() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); - assert_eq!(n_slides(&s), 3); - let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow"); - assert_eq!(r["slides"], 1); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 2); - let undo = &s.active().unwrap().history.undo; - assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…")); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(n_slides(&s), 3); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(pictures(&s, 2).len(), 1); - } - - #[test] - fn picture_options_and_theme_are_one_step() { - let mut s = Session::with_new(); - s.execute( - "insert.photoAlbum", - &json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}), - ) - .unwrap(); - let adj = adjust(&s, 1); - assert!(adj.grayscale); - assert!((adj.brightness - 0.2).abs() < 1e-6); - assert!((adj.contrast + 0.1).abs() < 1e-6); - assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01); - assert_eq!(theme_name(&s), "Harbor"); - let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); - assert!(mark.contains("gray=1"), "{mark}"); - assert!(mark.contains("theme=Harbor"), "{mark}"); - assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(theme_name(&s), "DeckCraft"); - assert_eq!(n_slides(&s), 1); - } - - #[test] - fn unknown_theme_and_a_plain_slide_change_nothing() { - let mut s = Session::with_new(); - let before = n_slides(&s); - let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err(); - assert!(err.to_string().contains("unknown theme"), "{err}"); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err()); - assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err()); - let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err(); - assert!(err.to_string().contains("not part of a photo album"), "{err}"); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap(); - s.execute("slide.go", &json!({"index": 0})).unwrap(); - assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err()); - assert_eq!(n_slides(&s), before + 1); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(theme_name(&s), "DeckCraft"); - } - - #[test] - fn two_albums_stay_independent() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); - let last = n_slides(&s) - 1; - s.execute("slide.go", &json!({"index": last})).unwrap(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap(); - assert_eq!(n_slides(&s), 5); - s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap(); - assert_eq!(n_slides(&s), 4); - assert_eq!(pictures(&s, 1).len(), 1); - assert_eq!(pictures(&s, 2).len(), 1); - assert_eq!(pictures(&s, 3).len(), 2); - let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); - let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default(); - assert!(first.starts_with("deckcraft-album:")); - assert!(second.starts_with("deckcraft-album:")); - assert_ne!(first.split(';').next(), second.split(';').next()); - } - - #[test] - fn a_named_run_without_a_marker_still_reflows() { - let mut s = Session::with_new(); - s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); - { - let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); - for sl in doc.slides.iter_mut().skip(1) { - std::sync::Arc::make_mut(sl).raw_ext = None; - } - } - s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap(); - assert_eq!(n_slides(&s), 2); - assert_eq!(pictures(&s, 1).len(), 2); - let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); - assert!(mark.starts_with("deckcraft-album:"), "{mark}"); - } -} +mod icon_and_picture_tests; diff --git a/crates/engine/src/cmd/insert/album.rs b/crates/engine/src/cmd/insert/album.rs new file mode 100644 index 000000000..bb2f349ed --- /dev/null +++ b/crates/engine/src/cmd/insert/album.rs @@ -0,0 +1,724 @@ +use super::picture::{content_type, media_bytes}; +use super::{NO_ID_SPACE, Result, Session, ShapeKind, Value, bad, base64_encode, json}; + +const ALBUM_MAX_ITEMS: usize = 48; + +const ALBUM_MAX_BYTES: usize = 32 * 1024 * 1024; + +const ALBUM_MAX_TOTAL: usize = 64 * 1024 * 1024; + +const ALBUM_MAX_TEXT: usize = 400; + +#[derive(Clone, Copy)] +struct AlbumLayout { + cols: usize, + rows: usize, + per: usize, + title: bool, + fit: bool, +} + +#[derive(Clone, Copy)] +enum AlbumFrame { + None, + Rect, + Rounded, + Simple, +} + +struct AlbumPic { + name: String, + bytes: Vec, + px_w: f64, + px_h: f64, + rot: f64, + brightness: f64, + contrast: f64, +} + +struct AlbumOpts { + layout_key: String, + frame_key: String, + layout: AlbumLayout, + frame: AlbumFrame, + captions: bool, + grayscale: bool, + theme: Option, +} + +enum AlbumItem { + Picture(AlbumPic), + Text(String), +} + +enum AlbumSlide { + Pictures(Vec), + Text(String), +} + +/// Insert a photo album after the current slide. +/// +/// PowerPoint starts a new presentation. These slides go into the open one, so the album is one +/// undo step. Omitting `pictures` asks the UI to open the dialog and does not edit. +pub(super) fn photo_album(s: &mut Session, p: &Value) -> Result { + if p.get("pictures").is_none_or(Value::is_null) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: json!({}) }); + return Ok(json!({"dialog": "photoAlbum"})); + } + let opts = album_opts(p)?; + let items = album_items(p)?; + commit_album(s, items, opts, None) +} + +pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result { + if p.get("pictures").is_none_or(Value::is_null) { + let seed = album_seed(s)?; + let album = seed.get("album").cloned().unwrap_or(Value::Null); + s.ui_requests.push(crate::UiRequest::Dialog { id: "photoAlbum".into(), params: seed }); + return Ok(json!({"dialog": "photoAlbum", "album": album})); + } + let opts = album_opts(p)?; + let items = album_items(p)?; + let (id, ids) = album_target(s, p)?; + commit_album(s, items, opts, Some((id, ids))) +} + +fn commit_album(s: &mut Session, items: Vec, opts: AlbumOpts, replace: Option<(u32, Vec)>) -> Result { + let (snapshot, sel) = { + let st = s.doc()?; + (st.doc.clone(), st.selection.clone()) + }; + let result = (|| { + let album_id = match replace.as_ref().map(|(id, _)| *id) { + Some(id) if id != 0 => id, + _ => { + let cmd = if replace.is_some() { "insert.photoAlbumEdit" } else { "insert.photoAlbum" }; + s.edit(|doc, _| doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE)))? + } + }; + let at = if let Some((_, ids)) = &replace { + let st = s.doc()?; + let indices: Vec = ids.iter().filter_map(|id| st.doc.slide_index(deckcraft_model::SlideId(*id as u32))).collect(); + let Some(at) = indices.iter().copied().min() else { + return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); + }; + s.execute("slide.delete", &json!({"ids": ids}))?; + Some(at) + } else { + None + }; + let mut opts = opts; + if opts.theme.as_deref().unwrap_or("").is_empty() { + opts.theme = s.doc().ok().and_then(|st| st.doc.masters.first().map(|m| m.theme.name.clone())).filter(|n| !n.is_empty()); + } + apply_theme_if_needed(s, opts.theme.as_deref())?; + insert_album(s, items, &opts, at, album_id) + })(); + if result.is_err() + && let Some(st) = s.active_mut() + { + st.doc = snapshot; + st.selection = sel; + } + result +} + +fn album_norm(s: &str) -> String { + s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase() +} + +fn album_layout(p: &Value) -> Result { + let key = match p.get("layout") { + None => return Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(bad("insert.photoAlbum", "`layout` must be text")), + }; + match album_norm(key).as_str() { + "fit" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: true }), + "1" | "one" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: false, fit: false }), + "1title" | "onetitle" => Ok(AlbumLayout { cols: 1, rows: 1, per: 1, title: true, fit: false }), + "2" | "two" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: false, fit: false }), + "2title" | "twotitle" => Ok(AlbumLayout { cols: 2, rows: 1, per: 2, title: true, fit: false }), + "4" | "four" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: false, fit: false }), + "4title" | "fourtitle" => Ok(AlbumLayout { cols: 2, rows: 2, per: 4, title: true, fit: false }), + _ => Err(bad("insert.photoAlbum", format!("unknown layout `{key}` (use fit, 1, 2, 4, 1title, 2title or 4title)"))), + } +} + +fn album_frame(p: &Value) -> Result { + let key = match p.get("frame") { + None => return Ok(AlbumFrame::None), + Some(Value::String(s)) => s.as_str(), + Some(_) => return Err(bad("insert.photoAlbum", "`frame` must be text")), + }; + match album_norm(key).as_str() { + "none" => Ok(AlbumFrame::None), + "rect" | "rectangle" => Ok(AlbumFrame::Rect), + "rounded" | "round" | "roundedrect" | "roundedrectangle" => Ok(AlbumFrame::Rounded), + "simple" | "simpleframe" | "simplewhite" | "white" => Ok(AlbumFrame::Simple), + _ => Err(bad("insert.photoAlbum", format!("unknown frame `{key}` (use none, rect, rounded or simple)"))), + } +} + +fn album_captions(p: &Value) -> Result { + match p.get("captions") { + None => Ok(false), + Some(Value::Bool(v)) => Ok(*v), + Some(_) => Err(bad("insert.photoAlbum", "`captions` must be true or false")), + } +} + +fn album_bool(p: &Value, key: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(v)) => Ok(*v), + Some(_) => Err(bad("insert.photoAlbum", format!("`{key}` must be true or false"))), + } +} + +fn layout_key_of(layout: AlbumLayout) -> String { + if layout.fit { + return "fit".into(); + } + let n = match layout.per { + 2 => "2", + 4 => "4", + _ => "1", + }; + if layout.title { format!("{n}title") } else { n.into() } +} + +fn frame_key_of(frame: AlbumFrame) -> &'static str { + match frame { + AlbumFrame::None => "none", + AlbumFrame::Rect => "rect", + AlbumFrame::Rounded => "rounded", + AlbumFrame::Simple => "simple", + } +} + +fn album_opts(p: &Value) -> Result { + let layout = album_layout(p)?; + let frame = album_frame(p)?; + let theme = match p.get("theme") { + None | Some(Value::Null) => None, + Some(Value::String(s)) if s.trim().is_empty() => None, + Some(Value::String(s)) => { + let name = s.trim(); + let found = deckcraft_model::theme::builtin_themes().into_iter().find(|t| t.name.eq_ignore_ascii_case(name)); + let Some(t) = found else { + return Err(bad("insert.photoAlbum", format!("unknown theme `{name}`"))); + }; + Some(t.name) + } + Some(_) => return Err(bad("insert.photoAlbum", "`theme` must be a theme name")), + }; + Ok(AlbumOpts { + layout_key: layout_key_of(layout), + frame_key: frame_key_of(frame).into(), + layout, + frame, + captions: album_captions(p)?, + grayscale: album_bool(p, "grayscale")?, + theme, + }) +} + +/// A finite number, or 0 when the key is absent. A present non-number is an error. +fn album_finite(item: &Value, key: &str) -> Result { + match item.get(key) { + None | Some(Value::Null) => Ok(0.0), + Some(v) => v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("insert.photoAlbum", format!("`{key}` must be a number"))), + } +} + +fn album_rot(item: &Value) -> Result { + let n = album_finite(item, "rot")?; + Ok(((n % 360.0) + 360.0) % 360.0) +} + +fn album_unit(item: &Value, key: &str) -> Result { + let n = album_finite(item, key)?; + if !(-1.0..=1.0).contains(&n) { + return Err(bad("insert.photoAlbum", format!("`{key}` must be between -1 and 1"))); + } + Ok(n) +} + +fn album_items(p: &Value) -> Result> { + let Some(arr) = p.get("pictures").and_then(Value::as_array) else { + return Err(bad("insert.photoAlbum", "`pictures` must be a list")); + }; + if arr.is_empty() { + return Err(bad("insert.photoAlbum", "add at least one picture or text box")); + } + if arr.len() > ALBUM_MAX_ITEMS { + return Err(bad("insert.photoAlbum", "a photo album holds at most 48 pictures or text boxes")); + } + let mut items = Vec::with_capacity(arr.len()); + let mut total = 0usize; + for item in arr { + let Some(obj) = item.as_object() else { + return Err(bad("insert.photoAlbum", "each album item must be a picture or a text box")); + }; + let has_media = obj.contains_key("data") || obj.contains_key("path"); + if !has_media { + let Some(text) = obj.get("text").and_then(Value::as_str) else { + return Err(bad("insert.photoAlbum", "each album item needs `path`, `data` or `text`")); + }; + let text = text.trim(); + if text.is_empty() { + return Err(bad("insert.photoAlbum", "a text box needs some text")); + } + if text.chars().count() > ALBUM_MAX_TEXT { + return Err(bad("insert.photoAlbum", "text is longer than 400 characters")); + } + items.push(AlbumItem::Text(text.to_string())); + continue; + } + let (name, bytes) = media_bytes(item, "insert.photoAlbum")?; + if bytes.len() > ALBUM_MAX_BYTES { + return Err(bad("insert.photoAlbum", format!("{name} is larger than 32 MB"))); + } + let next = total.saturating_add(bytes.len()); + if next > ALBUM_MAX_TOTAL { + return Err(bad("insert.photoAlbum", "the album is larger than 64 MB")); + } + total = next; + let ct = content_type(&name, &bytes); + if !ct.starts_with("image/") { + return Err(bad("insert.photoAlbum", format!("{name} is not a picture"))); + } + if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { + return Err(bad("insert.photoAlbum", format!("{name}: the picture can't be read"))); + } + let (px_w, px_h) = image_px(&bytes); + items.push(AlbumItem::Picture(AlbumPic { + name, + bytes, + px_w, + px_h, + rot: album_rot(item)?, + brightness: album_unit(item, "brightness")?, + contrast: album_unit(item, "contrast")?, + })); + } + Ok(items) +} + +fn image_px(bytes: &[u8]) -> (f64, f64) { + image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .map(|(w, h)| (w as f64, h as f64)) + .filter(|(w, h)| *w > 0.0 && *h > 0.0) + .unwrap_or((4.0, 3.0)) +} + +fn file_stem(name: &str) -> String { + let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name); + let stem = match base.rsplit_once('.') { + Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s, + _ => base, + }; + if stem.is_empty() { "Picture".to_string() } else { stem.to_string() } +} + +fn album_plan(items: Vec, per: usize) -> Vec { + let per = per.max(1); + let mut out = Vec::new(); + let mut batch = Vec::new(); + for item in items { + match item { + AlbumItem::Text(text) => { + if !batch.is_empty() { + out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); + } + out.push(AlbumSlide::Text(text)); + } + AlbumItem::Picture(pic) => { + batch.push(pic); + if batch.len() >= per { + out.push(AlbumSlide::Pictures(std::mem::take(&mut batch))); + } + } + } + } + if !batch.is_empty() { + out.push(AlbumSlide::Pictures(batch)); + } + out +} + +struct Placed { + pic: [f64; 4], + caption: Option<[f64; 4]>, +} + +fn fit_in(box_w: f64, box_h: f64, px_w: f64, px_h: f64) -> (f64, f64) { + let aspect = if px_h > 0.0 { px_w / px_h } else { 4.0 / 3.0 }; + let box_w = box_w.max(1.0); + let box_h = box_h.max(1.0); + if !aspect.is_finite() || aspect <= 0.0 { + return (box_w, box_h); + } + if aspect >= box_w / box_h { (box_w, (box_w / aspect).max(1.0)) } else { ((box_h * aspect).max(1.0), box_h) } +} + +fn place_album(width: f64, height: f64, layout: AlbumLayout, n: usize, captions: bool) -> (Option<[f64; 4]>, Vec) { + let margin = if layout.fit { 18.0 } else { 36.0 }; + let title_band = if layout.title { 48.0 } else { 0.0 }; + let gap = 12.0; + let cap_h = 22.0; + let cap_gap = 4.0; + let left = margin; + let top = margin + title_band; + let grid_w = (width - margin - left).max(1.0); + let grid_h = (height - margin - top).max(1.0); + let cols = layout.cols.max(1); + let rows = layout.rows.max(1); + let cell_w = ((grid_w - gap * (cols.saturating_sub(1) as f64)) / cols as f64).max(1.0); + let cell_h = ((grid_h - gap * (rows.saturating_sub(1) as f64)) / rows as f64).max(1.0); + let pic_h = if captions { (cell_h - cap_h - cap_gap).max(1.0) } else { cell_h }; + let mut placed = Vec::with_capacity(n); + for i in 0..n { + let col = i % cols; + let row = i / cols; + if row >= rows { + break; + } + let cell_x = left + col as f64 * (cell_w + gap); + let cell_y = top + row as f64 * (cell_h + gap); + placed.push(Placed { pic: [cell_x, cell_y, cell_w, pic_h], caption: None }); + } + let title = if layout.title { Some([margin, 8.0, (width - 2.0 * margin).max(1.0), 32.0]) } else { None }; + (title, placed) +} + +fn place_pictures(width: f64, height: f64, layout: AlbumLayout, pics: &[AlbumPic], captions: bool) -> (Option<[f64; 4]>, Vec) { + let (title, slots) = place_album(width, height, layout, pics.len(), captions); + let placed = pics + .iter() + .zip(slots) + .map(|(pic, slot)| { + let (dw, dh) = fit_in(slot.pic[2], slot.pic[3], pic.px_w, pic.px_h); + let x = slot.pic[0] + (slot.pic[2] - dw) / 2.0; + let y = slot.pic[1] + (slot.pic[3] - dh) / 2.0; + let caption = if captions { Some([x, y + dh + 4.0, dw.max(36.0), 22.0]) } else { None }; + Placed { pic: [x, y, dw, dh], caption } + }) + .collect(); + (title, placed) +} + +fn apply_frame(s: &mut Session, frame: AlbumFrame, id: u64) -> Result<()> { + let params = match frame { + AlbumFrame::None => return Ok(()), + AlbumFrame::Rect => json!({"id": id, "color": "#1A1A1A", "width": 1.5}), + AlbumFrame::Rounded => json!({"id": id, "color": "#1A1A1A", "width": 2.25, "join": "round"}), + AlbumFrame::Simple => json!({"id": id, "color": "#FFFFFF", "width": 6.0}), + }; + s.execute("shape.line", ¶ms)?; + Ok(()) +} + +struct AlbumMark { + id: u32, + layout: String, + frame: String, + captions: bool, + grayscale: bool, + theme: String, +} + +fn parse_album_mark(raw: &str) -> Option { + let rest = raw.strip_prefix("deckcraft-album:")?; + let (id_str, rest) = rest.split_once(';').unwrap_or((rest, "")); + let id = id_str.parse::().ok().filter(|id| *id != 0)?; + let mut mark = AlbumMark { id, layout: String::new(), frame: String::new(), captions: false, grayscale: false, theme: String::new() }; + if !rest.is_empty() { + for part in rest.split(';') { + let Some((k, v)) = part.split_once('=') else { continue }; + match k { + "layout" => mark.layout = v.to_string(), + "frame" => mark.frame = v.to_string(), + "captions" => mark.captions = v == "1", + "gray" => mark.grayscale = v == "1", + "theme" => mark.theme = v.to_string(), + _ => {} + } + } + } + Some(mark) +} + +fn album_marker(id: u32, opts: &AlbumOpts) -> String { + let theme = opts.theme.as_deref().unwrap_or(""); + format!( + "deckcraft-album:{id};layout={};frame={};captions={};gray={};theme={theme}", + opts.layout_key, + opts.frame_key, + if opts.captions { 1 } else { 0 }, + if opts.grayscale { 1 } else { 0 }, + ) +} + +fn stamp_album_slide(s: &mut Session, album_id: u32, opts: &AlbumOpts) -> Result<()> { + let marker = album_marker(album_id, opts); + s.edit(|doc, sel| { + let Some(slide) = doc.slides.get_mut(sel.slide) else { + return Err(bad("insert.photoAlbum", "no slide to mark")); + }; + let slide = std::sync::Arc::make_mut(slide); + if slide.raw_ext.as_deref().is_none_or(|ext| ext.starts_with("deckcraft-album:")) { + slide.raw_ext = Some(marker); + } + slide.name = "Photo Album".into(); + Ok(()) + }) +} + +fn apply_theme_if_needed(s: &mut Session, theme: Option<&str>) -> Result<()> { + let Some(name) = theme.filter(|n| !n.is_empty()) else { return Ok(()) }; + let current = s.doc()?.doc.masters.first().map(|m| m.theme.name.clone()).unwrap_or_default(); + if current.eq_ignore_ascii_case(name) { + return Ok(()); + } + s.execute("design.theme", &json!({"name": name}))?; + Ok(()) +} + +fn album_id_param(p: &Value) -> Option { + match p.get("album") { + Some(Value::Number(n)) => n.as_u64().and_then(|v| u32::try_from(v).ok()).filter(|v| *v != 0), + Some(Value::String(s)) => s.trim().parse::().ok().filter(|v| *v != 0), + _ => None, + } +} + +fn slides_with_album(doc: &deckcraft_model::Presentation, id: u32) -> Vec { + doc.slides.iter().filter(|s| s.raw_ext.as_deref().and_then(parse_album_mark).is_some_and(|m| m.id == id)).map(|s| u64::from(s.id.0)).collect() +} + +fn album_name_run(doc: &deckcraft_model::Presentation, index: usize) -> Vec { + if doc.slides.get(index).is_none_or(|s| s.name != "Photo Album") { + return Vec::new(); + } + let mut start = index; + while start > 0 && doc.slides.get(start - 1).is_some_and(|s| s.name == "Photo Album") { + start -= 1; + } + let mut end = index; + while doc.slides.get(end + 1).is_some_and(|s| s.name == "Photo Album") { + end += 1; + } + doc.slides[start..=end].iter().map(|s| u64::from(s.id.0)).collect() +} + +/// The album under the selection, or the one named by `album`. Id 0 is a name-only run (no marker). +fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec)> { + let st = s.doc()?; + if let Some(id) = album_id_param(p) { + let ids = slides_with_album(&st.doc, id); + if ids.is_empty() { + return Err(bad("insert.photoAlbumEdit", "that photo album is no longer in this presentation")); + } + return Ok((id, ids)); + } + let Some(slide) = st.doc.slides.get(st.selection.slide) else { + return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); + }; + if let Some(mark) = slide.raw_ext.as_deref().and_then(parse_album_mark) { + let ids = slides_with_album(&st.doc, mark.id); + if ids.is_empty() { + return Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")); + } + return Ok((mark.id, ids)); + } + if slide.name == "Photo Album" { + let ids = album_name_run(&st.doc, st.selection.slide); + if !ids.is_empty() { + return Ok((0, ids)); + } + } + Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album")) +} + +fn pic_count(slide: &deckcraft_model::Slide) -> usize { + slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count() +} + +fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String, String, bool, bool, String) { + let max_pics = slides.iter().map(|s| pic_count(s)).max().unwrap_or(1); + let per = if max_pics >= 3 { + "4" + } else if max_pics == 2 { + "2" + } else { + "1" + }; + let title = + slides.iter().any(|s| pic_count(s) > 0 && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| x.y < 36.0 && x.h <= 48.0))); + let layout = if title { format!("{per}title") } else { per.into() }; + let width = slides.iter().find_map(|s| { + s.shapes.iter().find_map(|sh| match &sh.kind { + ShapeKind::Picture { .. } => sh.line.as_ref().and_then(|l| l.width), + _ => None, + }) + }); + let frame = match width { + None => "none", + Some(w) if (w - 6.0).abs() < 0.2 => "simple", + Some(w) if (w - 2.25).abs() < 0.2 => "rounded", + Some(w) if w < 0.2 => "none", + Some(_) => "rect", + }; + let captions = slides.iter().any(|s| { + let bottoms: Vec = + s.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).filter_map(|sh| sh.xfrm.map(|x| x.y + x.h)).collect(); + !bottoms.is_empty() && s.shapes.iter().any(|sh| sh.text_box && sh.xfrm.is_some_and(|x| bottoms.iter().any(|b| x.y + 1.0 >= *b))) + }); + let mut any = false; + let mut gray = true; + for s in slides { + for sh in &s.shapes { + if let ShapeKind::Picture { fill } = &sh.kind { + any = true; + gray &= fill.adjust.grayscale; + } + } + } + (layout, frame.into(), captions, any && gray, theme_now.to_string()) +} + +fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec { + let mut items = Vec::new(); + for slide in slides { + let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect(); + if pics.is_empty() { + let text = slide + .shapes + .iter() + .filter(|sh| sh.text_box) + .filter_map(|sh| sh.text.as_ref().map(|t| t.text())) + .find(|t| !t.trim().is_empty()) + .unwrap_or_default(); + if !text.trim().is_empty() { + items.push(json!({"text": text.trim()})); + } + continue; + } + for sh in pics { + let ShapeKind::Picture { fill } = &sh.kind else { continue }; + let media = doc.media(fill.media); + let name = if !sh.descr.is_empty() { + sh.descr.clone() + } else { + media.map(|m| m.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "picture".into()) + }; + items.push(json!({ + "name": name, + "data": media.map(|m| base64_encode(&m.data)).unwrap_or_default(), + "rot": sh.xfrm.map(|x| x.rot).unwrap_or(0.0), + "brightness": fill.adjust.brightness, + "contrast": fill.adjust.contrast, + })); + } + } + items +} + +fn album_seed(s: &Session) -> Result { + let (id, ids) = album_target(s, &Value::Null)?; + let st = s.doc()?; + let slides: Vec<&deckcraft_model::Slide> = + ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect(); + let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into()); + let inferred = infer_album(&slides, &theme_now); + let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark)); + let (layout, frame, captions, grayscale, theme) = if let Some(mark) = mark { + ( + if mark.layout.is_empty() { inferred.0 } else { mark.layout }, + if mark.frame.is_empty() { inferred.1 } else { mark.frame }, + mark.captions, + mark.grayscale, + if mark.theme.is_empty() { theme_now } else { mark.theme }, + ) + } else { + inferred + }; + Ok(json!({ + "album": if id == 0 { Value::Null } else { json!(id) }, + "layout": layout, + "frame": frame, + "captions": captions, + "grayscale": grayscale, + "theme": theme, + "items": items_from_slides(&st.doc, &slides), + })) +} + +fn insert_album(s: &mut Session, items: Vec, opts: &AlbumOpts, at: Option, album_id: u32) -> Result { + let (width, height, first) = { + let st = s.doc()?; + let first = at.unwrap_or_else(|| if st.doc.slides.is_empty() { 0 } else { st.selection.slide.saturating_add(1) }); + (st.doc.slide_size.width, st.doc.slide_size.height, first) + }; + let layout = opts.layout; + let frame = opts.frame; + let captions = opts.captions; + let plan = album_plan(items, layout.per); + let mut slides = 0usize; + let mut pictures = 0usize; + let mut pin = at; + for slide in plan { + if let Some(index) = pin.take() { + s.execute("slide.new", &json!({"layout": "blank", "at": index}))?; + } else { + s.execute("slide.new", &json!({"layout": "blank"}))?; + } + stamp_album_slide(s, album_id, opts)?; + slides += 1; + match slide { + AlbumSlide::Text(text) => { + let rect = if layout.title { + [36.0, 18.0, (width - 72.0).max(36.0), 48.0] + } else { + [36.0, 36.0, (width - 72.0).max(36.0), (height - 72.0).max(36.0)] + }; + s.execute("insert.textBox", &json!({"rect": rect, "text": text}))?; + } + AlbumSlide::Pictures(pics) => { + let stem = pics.first().map(|p| file_stem(&p.name)).unwrap_or_default(); + let (title, placed) = place_pictures(width, height, layout, &pics, captions); + for (pic, cell) in pics.iter().zip(placed) { + let data = base64_encode(&pic.bytes); + let inserted = s.execute("insert.picture", &json!({"name": pic.name, "data": data, "rect": cell.pic}))?; + let Some(id) = inserted.get("id").and_then(Value::as_u64) else { + return Err(bad("insert.photoAlbum", "the picture was not inserted")); + }; + apply_frame(s, frame, id)?; + if opts.grayscale || pic.brightness.abs() > 1e-6 || pic.contrast.abs() > 1e-6 { + s.execute( + "picture.adjust", + &json!({"id": id, "grayscale": opts.grayscale, "brightness": pic.brightness, "contrast": pic.contrast}), + )?; + } + if pic.rot > 0.01 { + s.execute("shape.rotate", &json!({"id": id, "deg": pic.rot}))?; + } + pictures += 1; + if let Some(cap) = cell.caption { + s.execute("insert.textBox", &json!({"rect": cap, "text": file_stem(&pic.name)}))?; + } + } + if let Some(rect) = title { + s.execute("insert.textBox", &json!({"rect": rect, "text": stem}))?; + } + } + } + } + s.execute("slide.go", &json!({"index": first}))?; + Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id})) +} diff --git a/crates/engine/src/cmd/insert/chart.rs b/crates/engine/src/cmd/insert/chart.rs new file mode 100644 index 000000000..6c32ff561 --- /dev/null +++ b/crates/engine/src/cmd/insert/chart.rs @@ -0,0 +1,221 @@ +use super::{ + Chart, ChartType, Result, RunKind, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, bool_param, color_value, edit_shapes, json, + rect_param, str_param, +}; + +pub fn chart_type(name: &str) -> ChartType { + match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() { + "bar" | "clusteredbar" => ChartType::Bar, + "stackedbar" => ChartType::StackedBar, + "percentbar" => ChartType::PercentBar, + "line" => ChartType::Line, + "linemarkers" | "linewithmarkers" => ChartType::LineMarkers, + "stackedline" => ChartType::StackedLine, + "area" => ChartType::Area, + "stackedarea" => ChartType::StackedArea, + "pie" => ChartType::Pie, + "doughnut" | "donut" => ChartType::Doughnut, + "scatter" | "xy" => ChartType::Scatter, + "bubble" => ChartType::Bubble, + "radar" => ChartType::Radar, + "filledradar" => ChartType::FilledRadar, + "stackedcolumn" => ChartType::StackedColumn, + "percentcolumn" => ChartType::PercentColumn, + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "histogram" => ChartType::Histogram, + "pareto" => ChartType::Pareto, + "treemap" => ChartType::Treemap, + "sunburst" => ChartType::Sunburst, + "boxwhisker" | "box" => ChartType::BoxWhisker, + "stock" => ChartType::Stock, + "surface" => ChartType::Surface, + "combo" => ChartType::Combo, + _ => ChartType::Column, + } +} + +pub(super) fn chart(s: &mut Session, p: &Value) -> Result { + let kind = chart_type(str_param(p, "type").unwrap_or("column")); + let mut c = Chart::sample(kind); + if let Some(cats) = p.get("categories").and_then(Value::as_array) { + c.categories = cats.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect(); + } + if let Some(series) = p.get("series").and_then(Value::as_array) { + c.series = series + .iter() + .map(|sv| deckcraft_model::chart::Series { + name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), + values: sv.get("values").and_then(Value::as_array).map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(), + ..Default::default() + }) + .collect(); + } + if let Some(t) = p.get("title") { + c.title = t.as_str().map(String::from); + } + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or(Xfrm::new(size.width * 0.15, size.height * 0.15, size.width * 0.7, size.height * 0.7)); + let id = add_shape(s, "insert.chart", Shape { xfrm: Some(rect), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }, true)?; + Ok(json!({"id": id})) +} + +#[allow(dead_code)] +fn _kinds() -> RunKind { + RunKind::Text +} + +fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result { + edit_shapes(s, p, cmd, |sh| match &mut sh.kind { + ShapeKind::Chart(c) => f(c), + _ => Err(bad(cmd, "not a chart")), + }) +} + +pub(super) fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { + let k = chart_type(str_param(p, "type").unwrap_or("column")); + with_chart(s, p, "chart.type", |c| { + c.kind = k; + c.vary_colors = + matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) + || c.series.len() == 1 && c.vary_colors; + if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + c.gridlines = true; + } + c.raw = None; + Ok(()) + }) +} + +pub(super) fn chart_data(s: &mut Session, p: &Value) -> Result { + let cats: Option> = p + .get("categories") + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect()); + let series: Option> = p.get("series").and_then(Value::as_array).map(|a| { + a.iter() + .map(|sv| deckcraft_model::chart::Series { + name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(), + values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(), + ..Default::default() + }) + .collect() + }); + let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { + a.iter() + .take(deckcraft_model::chart::MAX_LEVELS) + .map(|l| { + l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() + }) + .collect() + }); + with_chart(s, p, "chart.data", |c| { + if let Some(cs) = &cats { + c.categories = cs.clone(); + } + if let Some(l) = &levels { + c.category_levels = l.clone(); + } + if let Some(se) = &series { + // Keep series formatting by position. + let old = std::mem::take(&mut c.series); + c.series = se + .iter() + .enumerate() + .map(|(i, n)| { + let mut x = old.get(i).cloned().unwrap_or_default(); + x.name = n.name.clone(); + x.values = n.values.clone(); + x + }) + .collect(); + } + c.raw = None; + Ok(()) + }) +} + +fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { + if let Some(n) = bin_count { + c.bin_count = n; + if n.is_some() { + c.bin_size = None; + } + } + if let Some(w) = bin_size { + c.bin_size = w; + if w.is_some() { + c.bin_count = None; + } + } +} + +fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { + if let Some(t) = subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); + t.sort_unstable(); + t.dedup(); + c.subtotals = t; + } + if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { + c.up_down_bars = b; + c.raw = None; + } + if let Some(q) = p.get("quartiles").and_then(Value::as_str) { + c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); + c.raw = None; + } +} + +pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result { + // Histogram bins: a count or a width (setting one clears the other; null for automatic). + let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); + let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); + if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { + return Err(bad("chart.options", "binSize must be a positive number")); + } + // Waterfall total points. + let subtotals: Option> = + p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); + // Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's + // default (tx1 at lumMod 65 % / lumOff 35 %). + let text_color = match p.get("textColor") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => { + Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?)) + } + }; + with_chart(s, p, "chart.options", |c| { + if let Some(t) = &text_color { + c.text_color = t.clone(); + } + apply_bin_options(c, bin_count, bin_size); + apply_subtotals_and_bars(c, p, subtotals.as_deref()); + if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { + c.raw = None; + } + if let Some(t) = p.get("title") { + c.title = t.as_str().map(String::from); + } + if let Some(l) = p.get("legend") { + c.legend = l.as_str().map(String::from); + } + if let Some(v) = bool_param(p, "dataLabels") { + c.data_labels = v; + // A series' own setting would otherwise keep overriding the chart-wide switch. + for series in &mut c.series { + series.data_labels = None; + } + } + if let Some(v) = bool_param(p, "gridlines") { + c.gridlines = v; + } + if let Some(v) = str_param(p, "palette") { + c.palette = v.to_string(); + } + c.raw = None; + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/insert/icon_and_picture_tests.rs b/crates/engine/src/cmd/insert/icon_and_picture_tests.rs new file mode 100644 index 000000000..f9b06f343 --- /dev/null +++ b/crates/engine/src/cmd/insert/icon_and_picture_tests.rs @@ -0,0 +1,284 @@ +use deckcraft_model::ShapeKind; +use deckcraft_model::style::LineJoin; + +use super::*; + +/// 8×8 red PNG. +fn png() -> Vec { + vec![ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 8, 0, 0, 0, 8, 8, 2, 0, 0, 0, 75, 109, 41, 220, 0, 0, 0, 17, 73, 68, + 65, 84, 120, 156, 99, 184, 163, 161, 129, 21, 49, 12, 45, 9, 0, 123, 123, 75, 1, 251, 162, 81, 240, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, + 130, + ] +} + +fn pic(name: &str) -> Value { + json!({"name": name, "data": base64_encode(&png())}) +} + +fn n_slides(s: &Session) -> usize { + s.active().map(|d| d.doc.slides.len()).unwrap_or(0) +} + +fn pictures(s: &Session, index: usize) -> Vec { + s.active() + .and_then(|d| d.doc.slides.get(index)) + .map(|sl| sl.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).cloned().collect()) + .unwrap_or_default() +} + +fn box_text(s: &Session, index: usize) -> Vec { + s.active() + .and_then(|d| d.doc.slides.get(index)) + .map(|sl| sl.shapes.iter().filter(|sh| sh.text_box).filter_map(|sh| sh.text.as_ref().map(|t| t.text())).collect()) + .unwrap_or_default() +} + +#[test] +fn two_pictures_one_per_slide_are_one_undo() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let r = s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).expect("album"); + assert_eq!(r["slides"], 2); + assert_eq!(n_slides(&s), before + 2); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); + assert_eq!(s.active().unwrap().selection.slide, 1); + let undo = &s.active().unwrap().history.undo; + assert_eq!(undo.len(), 1); + assert_eq!(undo[0].label, "Photo Album…"); + s.execute("edit.undo", &json!({})).expect("undo"); + assert_eq!(n_slides(&s), before); + assert!(pictures(&s, 1).is_empty()); +} + +#[test] +fn two_up_shares_one_slide() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).expect("album"); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + assert!(box_text(&s, 1).is_empty()); +} + +#[test] +fn captions_and_titles_use_the_file_stem() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1", "captions": true})).expect("captions"); + assert_eq!(box_text(&s, 1), ["harbor"]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("harbor.png")], "layout": "1title"})).expect("title"); + assert_eq!(box_text(&s, 1), ["harbor"]); + assert_eq!(pictures(&s, 1).len(), 1); + let pic_y = pictures(&s, 1)[0].xfrm.unwrap().y; + assert!(pic_y > 40.0, "the picture sits below the title"); +} + +#[test] +fn frames_set_the_picture_line() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rect"})).expect("rect"); + let line = pictures(&s, 1)[0].line.clone().expect("line"); + assert_eq!(line.width, Some(1.5)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "rounded"})).expect("rounded"); + let line = pictures(&s, 1)[0].line.clone().expect("line"); + assert_eq!(line.width, Some(2.25)); + assert_eq!(line.join, Some(LineJoin::Round)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "simple"})).expect("simple"); + assert_eq!(pictures(&s, 1)[0].line.as_ref().and_then(|l| l.width), Some(6.0)); +} + +#[test] +fn a_text_box_breaks_the_picture_row() { + let mut s = Session::with_new(); + let r = s + .execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"text": "A day on the water"}, pic("b.png")], "layout": "2"})) + .expect("album"); + assert_eq!(r["slides"], 3); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(box_text(&s, 2), ["A day on the water"]); + assert!(pictures(&s, 2).is_empty()); + assert_eq!(pictures(&s, 3).len(), 1); + assert_eq!(s.active().unwrap().history.undo.len(), 1); +} + +#[test] +fn no_pictures_opens_the_dialog_without_editing() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let r = s.execute("insert.photoAlbum", &json!({})).expect("open"); + assert_eq!(r["dialog"], "photoAlbum"); + assert_eq!(n_slides(&s), before); + assert!(s.active().unwrap().history.undo.is_empty()); + assert_eq!(s.ui_requests.len(), 1); + match &s.ui_requests[0] { + crate::UiRequest::Dialog { id, .. } => assert_eq!(id, "photoAlbum"), + other => panic!("dialog request, got {other:?}"), + } +} + +#[test] +fn bad_input_adds_no_slides() { + let mut s = Session::with_new(); + let before = n_slides(&s); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [], "layout": "1"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "grid"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "frame": "glow"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "captions": "yes"})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "notes.txt", "data": base64_encode(b"hello")}]} )).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"text": " "}]})).is_err()); + let mut many = Vec::new(); + for i in 0..49 { + many.push(json!({"text": format!("box {i}")})); + } + assert!(s.execute("insert.photoAlbum", &json!({"pictures": many})).is_err()); + assert_eq!(n_slides(&s), before); + assert!(s.active().unwrap().history.undo.is_empty()); +} + +#[test] +fn fit_is_larger_than_one_picture_and_four_up_is_one_slide() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "1"})).unwrap(); + let one = pictures(&s, 1)[0].xfrm.unwrap().h; + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "layout": "fit"})).unwrap(); + let fitted = pictures(&s, 1)[0].xfrm.unwrap().h; + assert!(fitted > one, "fit {fitted} should be taller than one-up {one}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png"), pic("c.png"), pic("d.png")], "layout": "4"})).unwrap(); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 4); +} + +fn adjust(s: &Session, index: usize) -> deckcraft_model::style::PictureAdjust { + match &pictures(s, index)[0].kind { + ShapeKind::Picture { fill } => fill.adjust.clone(), + _ => panic!("picture"), + } +} + +fn theme_name(s: &Session) -> String { + s.active().and_then(|d| d.doc.masters.first()).map(|m| m.theme.name.clone()).unwrap_or_default() +} + +#[test] +fn edit_without_pictures_reopens_the_dialog() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); + s.ui_requests.clear(); + let before = n_slides(&s); + let undos = s.active().unwrap().history.undo.len(); + let r = s.execute("insert.photoAlbumEdit", &json!({})).expect("reopen"); + assert_eq!(r["dialog"], "photoAlbum"); + assert_eq!(n_slides(&s), before); + assert_eq!(s.active().unwrap().history.undo.len(), undos); + let params = match &s.ui_requests[0] { + crate::UiRequest::Dialog { id, params } => { + assert_eq!(id, "photoAlbum"); + params + } + other => panic!("dialog request, got {other:?}"), + }; + assert_eq!(params["items"][0]["name"], "hill.png"); + assert_eq!(params["items"][1]["name"], "lake.png"); + assert_eq!(params["layout"], "1"); + assert!(params["album"].as_u64().unwrap_or(0) > 0); +} + +#[test] +fn edit_reflows_the_album_in_one_undo() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "1"})).unwrap(); + assert_eq!(n_slides(&s), 3); + let r = s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("hill.png"), pic("lake.png")], "layout": "2"})).expect("reflow"); + assert_eq!(r["slides"], 1); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + let undo = &s.active().unwrap().history.undo; + assert_eq!(undo.last().map(|u| u.label.as_str()), Some("Edit Photo Album…")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(n_slides(&s), 3); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); +} + +#[test] +fn picture_options_and_theme_are_one_step() { + let mut s = Session::with_new(); + s.execute( + "insert.photoAlbum", + &json!({"pictures": [{"name": "hill.png", "data": base64_encode(&png()), "rot": 90, "brightness": 0.2, "contrast": -0.1}], "grayscale": true, "theme": "Harbor"}), + ) + .unwrap(); + let adj = adjust(&s, 1); + assert!(adj.grayscale); + assert!((adj.brightness - 0.2).abs() < 1e-6); + assert!((adj.contrast + 0.1).abs() < 1e-6); + assert!((pictures(&s, 1)[0].xfrm.unwrap().rot - 90.0).abs() < 0.01); + assert_eq!(theme_name(&s), "Harbor"); + let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); + assert!(mark.contains("gray=1"), "{mark}"); + assert!(mark.contains("theme=Harbor"), "{mark}"); + assert_eq!(s.active().unwrap().doc.slides[1].name, "Photo Album"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(theme_name(&s), "DeckCraft"); + assert_eq!(n_slides(&s), 1); +} + +#[test] +fn unknown_theme_and_a_plain_slide_change_nothing() { + let mut s = Session::with_new(); + let before = n_slides(&s); + let err = s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")], "theme": "NotATheme"})).unwrap_err(); + assert!(err.to_string().contains("unknown theme"), "{err}"); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), {"brightness": "high"}]})).is_err()); + assert!(s.execute("insert.photoAlbum", &json!({"pictures": [{"name": "a.png", "data": base64_encode(&png()), "brightness": 2}]})).is_err()); + let err = s.execute("insert.photoAlbumEdit", &json!({})).unwrap_err(); + assert!(err.to_string().contains("not part of a photo album"), "{err}"); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png")]})).unwrap(); + s.execute("slide.go", &json!({"index": 0})).unwrap(); + assert!(s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("b.png")], "layout": "2"})).is_err()); + assert_eq!(n_slides(&s), before + 1); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(theme_name(&s), "DeckCraft"); +} + +#[test] +fn two_albums_stay_independent() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); + let last = n_slides(&s) - 1; + s.execute("slide.go", &json!({"index": last})).unwrap(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "1"})).unwrap(); + assert_eq!(n_slides(&s), 5); + s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("c.png"), pic("d.png")], "layout": "2"})).unwrap(); + assert_eq!(n_slides(&s), 4); + assert_eq!(pictures(&s, 1).len(), 1); + assert_eq!(pictures(&s, 2).len(), 1); + assert_eq!(pictures(&s, 3).len(), 2); + let first = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); + let second = s.active().unwrap().doc.slides[3].raw_ext.clone().unwrap_or_default(); + assert!(first.starts_with("deckcraft-album:")); + assert!(second.starts_with("deckcraft-album:")); + assert_ne!(first.split(';').next(), second.split(';').next()); +} + +#[test] +fn a_named_run_without_a_marker_still_reflows() { + let mut s = Session::with_new(); + s.execute("insert.photoAlbum", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "1"})).unwrap(); + { + let doc = std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc); + for sl in doc.slides.iter_mut().skip(1) { + std::sync::Arc::make_mut(sl).raw_ext = None; + } + } + s.execute("insert.photoAlbumEdit", &json!({"pictures": [pic("a.png"), pic("b.png")], "layout": "2"})).unwrap(); + assert_eq!(n_slides(&s), 2); + assert_eq!(pictures(&s, 1).len(), 2); + let mark = s.active().unwrap().doc.slides[1].raw_ext.clone().unwrap_or_default(); + assert!(mark.starts_with("deckcraft-album:"), "{mark}"); +} diff --git a/crates/engine/src/cmd/insert/links.rs b/crates/engine/src/cmd/insert/links.rs new file mode 100644 index 000000000..3e40cb91d --- /dev/null +++ b/crates/engine/src/cmd/insert/links.rs @@ -0,0 +1,119 @@ +use super::{ + Geom, Result, SchemeSlot, Session, Shape, ShapeStyle, Value, add_shape, bad, bool_or, bool_param, default_rect, edit_shapes, json, rect_param, + str_param, usize_param, +}; + +pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + // `display`: what a caret with nothing to link inserts, the address or the slide's title. + let (action, display) = if let Some(u) = str_param(p, "url") { + (Action::Url { url: u.to_string() }, u.strip_prefix("mailto:").unwrap_or(u).to_string()) + } else if let Some(i) = usize_param(p, "slide") { + let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad("insert.hyperlink", "no such slide"))?; + let title = slide.title(); + (Action::Slide { slide: slide.id }, if title.is_empty() { format!("Slide {}", i + 1) } else { title }) + } else { + return Err(bad("insert.hyperlink", "missing `url` or `slide`")); + }; + let display = str_param(p, "text").map_or(display, str::to_string); + let link = Hyperlink { + action, + tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), + highlight_click: bool_or(p, "highlightClick", false), + sound: None, + stop_sound: false, + zoom_return: None, + }; + if s.doc()?.selection.text.is_some() { + return crate::cmd::text::link_text(s, link, &display); + } + edit_shapes(s, p, "insert.hyperlink", |sh| { + sh.click = Some(link.clone()); + Ok(()) + }) +} + +/// Insert ▸ Action (Action Settings): what clicking the selected shapes, or pointing at them, does +/// in a show. +pub(super) fn shape_action(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + const CMD: &str = "shape.action"; + let hover = match str_param(p, "on").unwrap_or("click") { + "click" => false, + "hover" | "over" => true, + o => return Err(bad(CMD, format!("`on` is click or hover, not `{o}`"))), + }; + let need = |k: &str| str_param(p, k).map(str::to_string).filter(|v| !v.trim().is_empty()).ok_or_else(|| bad(CMD, format!("missing `{k}`"))); + let action = match str_param(p, "action").unwrap_or("none") { + "none" => None, + "next" => Some(Action::NextSlide), + "previous" => Some(Action::PreviousSlide), + "first" => Some(Action::FirstSlide), + "last" => Some(Action::LastSlide), + "lastViewed" => Some(Action::LastViewed), + "endShow" => Some(Action::EndShow), + "playMedia" => Some(Action::PlayMedia), + "url" => Some(Action::Url { url: need("url")? }), + "customShow" => { + let name = need("show")?; + if !s.doc()?.doc.custom_shows.iter().any(|c| c.name == name) { + return Err(bad(CMD, format!("no custom show `{name}`"))); + } + Some(Action::CustomShow { name, returns: true }) + } + "program" => Some(Action::Program { path: need("path")? }), + "slide" => { + let i = usize_param(p, "slide").ok_or_else(|| bad(CMD, "missing `slide`"))?; + let slide = s.doc()?.doc.slides.get(i).ok_or_else(|| bad(CMD, "no such slide"))?; + Some(Action::Slide { slide: slide.id }) + } + a => return Err(bad(CMD, format!("unknown action `{a}`"))), + }; + let highlight = bool_param(p, "highlight").unwrap_or(true); + let tooltip = str_param(p, "tooltip").unwrap_or("").to_string(); + let link = action.map(|action| Hyperlink { action, tooltip, highlight_click: highlight, sound: None, stop_sound: false, zoom_return: None }); + edit_shapes(s, p, CMD, |sh| { + if hover { + sh.hover = link.clone(); + } else { + sh.click = link.clone(); + } + Ok(()) + }) +} + +pub(super) fn action_button(s: &mut Session, p: &Value) -> Result { + use deckcraft_model::text::{Action, Hyperlink}; + let kind = str_param(p, "kind").unwrap_or("custom"); + let (preset, action) = match kind { + "back" | "previous" => ("actionButtonBackPrevious", Some(Action::PreviousSlide)), + "forward" | "next" => ("actionButtonForwardNext", Some(Action::NextSlide)), + "beginning" => ("actionButtonBeginning", Some(Action::FirstSlide)), + "end" => ("actionButtonEnd", Some(Action::LastSlide)), + "home" => ("actionButtonHome", Some(Action::FirstSlide)), + "information" => ("actionButtonInformation", None), + "return" => ("actionButtonReturn", Some(Action::LastViewed)), + "movie" => ("actionButtonMovie", None), + "document" => ("actionButtonDocument", None), + "sound" => ("actionButtonSound", None), + "help" => ("actionButtonHelp", None), + _ => ("actionButtonBlank", None), + }; + let rect = rect_param(p, "rect").unwrap_or_else(|| default_rect(s, 48.0, 48.0)); + let shape = Shape { + xfrm: Some(rect), + geom: Geom::preset(preset), + style: Some(ShapeStyle::accent(SchemeSlot::Accent1)), + click: action.map(|a| Hyperlink { + action: a, + tooltip: String::new(), + highlight_click: true, + sound: None, + stop_sound: false, + zoom_return: None, + }), + ..Default::default() + }; + let id = add_shape(s, "insert.actionButton", shape, true)?; + Ok(json!({"id": id})) +} diff --git a/crates/engine/src/cmd/insert/picture.rs b/crates/engine/src/cmd/insert/picture.rs new file mode 100644 index 000000000..85dafa7aa --- /dev/null +++ b/crates/engine/src/cmd/insert/picture.rs @@ -0,0 +1,246 @@ +use super::{ + EngineError, MediaClip, NO_ID_SPACE, PictureFill, Result, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, base64_decode, default_rect, + json, rect_param, str_param, xfrm_of, +}; + +/// Read media bytes from `data` (base64) or `path`. +pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { + if let Some(d) = str_param(p, "data") { + let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?; + return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes)); + } + if let Some(path) = str_param(p, "path") { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into()); + return Ok((name, bytes)); + } + Err(bad(cmd, "missing `path` or `data`")) +} + +pub fn content_type(name: &str, bytes: &[u8]) -> &'static str { + let l = name.to_ascii_lowercase(); + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { + "image/png" + } else if bytes.starts_with(&[0xFF, 0xD8]) { + "image/jpeg" + } else if bytes.starts_with(b"GIF8") { + "image/gif" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WAVE") { + "audio/wav" + } else if bytes.starts_with(b"BM") { + "image/bmp" + } else if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") { + "image/tiff" + } else if bytes.starts_with(b"fLaC") { + "audio/flac" + } else if bytes.starts_with(b"OggS") { + if l.ends_with(".ogv") { "video/ogg" } else { "audio/ogg" } + } else if bytes.len() > 1 && bytes[0] == 0xFF && bytes[1] & 0xF6 == 0xF0 { + // ADTS (layer bits 00): AAC. + "audio/aac" + } else if bytes.starts_with(b"ID3") || (bytes.len() > 1 && bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0 && !l.ends_with(".aac")) { + "audio/mpeg" + } else if bytes.starts_with(b"FORM") { + "audio/aiff" + } else if bytes.get(4..8) == Some(b"ftyp") { + if l.ends_with(".m4a") || l.ends_with(".m4b") || bytes.get(8..11) == Some(b"M4A") { + "audio/mp4" + } else if l.ends_with(".mov") || bytes.get(8..12) == Some(b"qt ") { + "video/quicktime" + } else { + "video/mp4" + } + } else if bytes.starts_with(&[0x1A, 0x45, 0xDF, 0xA3]) { + if l.ends_with(".mka") || l.ends_with(".weba") { + "audio/webm" + } else if l.ends_with(".mkv") { + "video/x-matroska" + } else { + "video/webm" + } + } else if bytes.starts_with(&[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { + if l.ends_with(".wmv") || l.ends_with(".asf") { "video/x-ms-wmv" } else { "audio/x-ms-wma" } + } else if bytes.starts_with(b"caff") { + "audio/x-caf" + } else if l.ends_with(".svg") || bytes.windows(4).take(512).any(|w| w == b" (f64, f64) { + let (w, h) = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .map(|(w, h)| (w as f64 * 0.75, h as f64 * 0.75)) + .unwrap_or((288.0, 216.0)); + let size = s.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + let k = (size.width * 0.9 / w.max(1.0)).min(size.height * 0.9 / h.max(1.0)).min(1.0); + (w * k, h * k) +} + +pub(super) fn picture(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = media_bytes(p, "insert.picture")?; + let ct = content_type(&name, &bytes); + if !ct.starts_with("image/") { + return Err(bad("insert.picture", format!("{name} is not a picture"))); + } + if ct != "image/svg+xml" && deckcraft_render::decode_image(&bytes).is_none() { + return Err(EngineError::Other(format!("{name}: the picture can't be read"))); + } + let (w, h) = fitted(s, &bytes); + // Into a selected empty picture/content placeholder, if there is one. + let target_ph = s.active().and_then(|d| { + d.selected_shapes() + .into_iter() + .find(|x| { + x.ph_type() + .is_some_and(|k| matches!(k, deckcraft_model::PhType::Picture | deckcraft_model::PhType::Obj | deckcraft_model::PhType::Body)) + && x.text.as_ref().is_none_or(|t| t.is_empty()) + }) + .map(|x| (x.id, xfrm_of(&d.doc, &d.selection, x))) + }); + let rect = rect_param(p, "rect").unwrap_or_else(|| match target_ph { + Some((_, b)) => { + // Fill the placeholder, cropping the picture to its aspect. + b + } + None => default_rect(s, w, h), + }); + let crop = match (target_ph, rect_param(p, "rect")) { + (Some(_), None) => { + let (ia, ba) = (w / h.max(1e-9), rect.w / rect.h.max(1e-9)); + if ia > ba { + let c = (1.0 - ba / ia) / 2.0; + [c, 0.0, c, 0.0] + } else { + let c = (1.0 - ia / ba) / 2.0; + [0.0, c, 0.0, c] + } + } + _ => [0.0; 4], + }; + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("insert.picture", NO_ID_SPACE)))?; + let shape = Shape { + xfrm: Some(rect), + kind: ShapeKind::Picture { fill: PictureFill { media, crop, ..Default::default() } }, + descr: name.clone(), + ..Default::default() + }; + if let Some((ph_id, _)) = target_ph { + // Replace the placeholder with the picture (it keeps the placeholder link). + let phv = s.doc()?.shape(ph_id).and_then(|x| x.ph.clone()); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert.picture", "no slide"))?; + if let Some(sh) = list.iter_mut().find(|x| x.id == ph_id) { + sh.kind = shape.kind.clone(); + sh.xfrm = Some(rect); + sh.ph = phv.map(|mut p| { + p.kind = deckcraft_model::PhType::Picture; + p + }); + sh.text = None; + sh.descr = name.clone(); + } + sel.shapes = vec![ph_id]; + Ok(()) + })?; + return Ok(json!({"id": ph_id})); + } + let id = add_shape(s, "insert.picture", shape, true)?; + Ok(json!({"id": id})) +} + +fn media(s: &mut Session, p: &Value, video: bool) -> Result { + let cmd = if video { "insert.video" } else { "insert.audio" }; + let (name, bytes) = media_bytes(p, cmd)?; + let ct = content_type(&name, &bytes); + let bytes = std::sync::Arc::new(bytes); + let info = deckcraft_media::probe(&bytes); + let has_video = info.as_ref().is_ok_and(|i| i.video.is_some()); + let has_audio = info.as_ref().is_ok_and(|i| i.audio.is_some()); + match &info { + Ok(_) if video && !has_video => return Err(bad(cmd, format!("{name} has no video"))), + Ok(_) if !video && !has_audio && !has_video => return Err(bad(cmd, format!("{name} has no audio"))), + // Not something we can read: embed it anyway when the type says it's media (it still + // round-trips through PPTX), otherwise refuse. + Err(_) if video && !ct.starts_with("video/") => return Err(bad(cmd, format!("{name} is not a supported video ({ct})"))), + Err(_) if !video && !ct.starts_with("audio/") => return Err(bad(cmd, format!("{name} is not a supported audio file ({ct})"))), + _ => {} + } + let (duration_ms, width, height, playable) = match &info { + Ok(i) => { + let (w, h) = i.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0)); + (i.duration_ms.min(u32::MAX as u64) as u32, w, h, i.playable()) + } + Err(_) => (0, 0, 0, false), + }; + let decodable_video = info.as_ref().is_ok_and(|i| i.video.as_ref().is_some_and(|v| v.decodable)); + let warning = if !playable { + Some(match &info { + Ok(i) => { + let codecs: Vec<&str> = i.audio.iter().map(|a| a.codec.as_str()).chain(i.video.iter().map(|v| v.codec.as_str())).collect(); + format!("{name} was inserted, but it can't be played ({} isn't supported yet)", codecs.join(" / ")) + } + Err(e) => format!("{name} was inserted, but it can't be played ({e})"), + }) + } else if video && !decodable_video { + // A video codec without a decoder (VP8, say): the clip's audio still plays, but no frame + // can ever show — say so instead of leaving an unexplained black box (#1237). + info.as_ref() + .ok() + .and_then(|i| i.video.as_ref()) + .map(|v| format!("{name} was inserted, but its video can't be played ({} isn't supported yet); only the audio plays", v.codec)) + } else { + None + }; + let poster_png = if video && decodable_video { crate::cmd::media::poster_png(&bytes, 0) } else { None }; + let size = s.doc()?.doc.slide_size; + let rect = rect_param(p, "rect").unwrap_or_else(|| { + if video { + // Natural size (96 dpi), fitted inside 90 % of the slide and centred. + let (w, h) = + if width > 0 && height > 0 { (width as f64 * 0.75, height as f64 * 0.75) } else { (size.width * 0.6, size.width * 0.6 * 9.0 / 16.0) }; + let k = (size.width * 0.9 / w).min(size.height * 0.9 / h).min(1.0); + let (w, h) = (w * k, h * k); + Xfrm::new((size.width - w) / 2.0, (size.height - h) / 2.0, w, h) + } else { + Xfrm::new((size.width - 48.0) / 2.0, (size.height - 48.0) / 2.0, 48.0, 48.0) + } + }); + let data = std::sync::Arc::try_unwrap(bytes).unwrap_or_else(|a| (*a).clone()); + let (media, poster) = s.edit(|doc, _| { + let media = doc.add_media(&name, ct, data).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?; + let poster = match poster_png { + Some(png) => Some(doc.add_media(&format!("{name}-poster.png"), "image/png", png).ok_or_else(|| bad("insert.media", NO_ID_SPACE))?), + None => None, + }; + Ok((media, poster)) + })?; + let clip = MediaClip { media, video, poster, volume: 1.0, duration_ms, width, height, ..Default::default() }; + let id = add_shape(s, "insert.media", Shape { xfrm: Some(rect), kind: ShapeKind::Media(clip), descr: name, ..Default::default() }, true)?; + if let Some(w) = &warning { + s.ui_requests.push(crate::UiRequest::Message { text: w.clone() }); + } + let mut r = + json!({"id": id, "contentType": ct, "durationMs": duration_ms, "width": width, "height": height, "playable": playable, "warning": warning}); + if let Ok(i) = &info { + r["probe"] = crate::cmd::media::probe_json(i); + } + Ok(r) +} + +pub(super) fn audio(s: &mut Session, p: &Value) -> Result { + media(s, p, false) +} + +pub(super) fn video(s: &mut Session, p: &Value) -> Result { + media(s, p, true) +} diff --git a/crates/engine/src/cmd/insert/text_fields.rs b/crates/engine/src/cmd/insert/text_fields.rs new file mode 100644 index 000000000..2a04f30bc --- /dev/null +++ b/crates/engine/src/cmd/insert/text_fields.rs @@ -0,0 +1,246 @@ +use super::{ + ColorRef, Fill, Result, RunKind, SchemeSlot, Session, TextBody, Value, Xfrm, add_shape, bad, bool_or, bool_param, json, new_text_box, ok, + str_param, usize_param, +}; + +pub(super) fn word_art(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").unwrap_or("Your text here"); + let size = s.doc()?.doc.slide_size; + let style = usize_param(p, "style").unwrap_or(0); + let mut shape = new_text_box(Xfrm::new(size.width * 0.2, size.height * 0.4, size.width * 0.6, 80.0), text); + if let Some(t) = shape.text.as_mut() { + for para in &mut t.paragraphs { + para.props.align = Some(deckcraft_model::text::Align::Center); + for r in &mut para.runs { + r.props.size = Some(54.0); + r.props.bold = Some(style % 2 == 1); + r.props.fill = Some(Fill::solid(ColorRef::scheme(match style % 4 { + 0 => SchemeSlot::Tx1, + 1 => SchemeSlot::Accent1, + 2 => SchemeSlot::Accent2, + _ => SchemeSlot::Accent4, + }))); + if style % 3 == 2 { + r.props.outline = Some(deckcraft_model::Line::solid(ColorRef::scheme(SchemeSlot::Bg1), 1.0)); + } + } + } + } + let id = add_shape(s, "insert.wordArt", shape, true)?; + crate::cmd::text::fit_text_box(s, id)?; + Ok(json!({"id": id})) +} + +fn field_run(s: &mut Session, field: &str, text: &str) -> Result { + if s.doc()?.selection.text.is_some() { + return crate::cmd::text::insert_field(s, field, text); + } + // Not editing: turn on the footer field for the slide (Header & Footer). + let key = if field == "slidenum" { "slideNumber" } else { "date" }; + s.execute("design.headerFooter", &json!({key: true})) +} + +pub(super) fn slide_number(s: &mut Session, _p: &Value) -> Result { + field_run(s, "slidenum", "‹#›") +} + +const DATE_FORMATS: [&str; 13] = [ + "datetime1", + "datetime2", + "datetime3", + "datetime4", + "datetime5", + "datetime6", + "datetime7", + "datetime8", + "datetime9", + "datetime10", + "datetime11", + "datetime12", + "datetime13", +]; + +struct DateOpts { + format: String, + update: bool, + /// BCP 47 tag when the caller set `language`; absent keeps the surrounding run's language. + lang: Option, + sample: String, +} + +/// `true` when the call carries a format, update flag or language (apply it). Empty params open +/// the dialog instead. +fn date_time_apply(p: &Value) -> bool { + p.as_object().is_some_and(|o| o.contains_key("format") || o.contains_key("update") || o.contains_key("language")) +} + +fn canon_lang(raw: &str) -> Option<&'static str> { + match raw.trim().to_ascii_lowercase().replace('_', "-").as_str() { + "en" | "en-us" => Some("en-US"), + "en-gb" => Some("en-GB"), + "fr" | "fr-fr" => Some("fr-FR"), + "ja" | "ja-jp" => Some("ja-JP"), + "sv" | "sv-se" => Some("sv-SE"), + _ => None, + } +} + +fn date_opts(p: &Value) -> Result { + if p.get("format").is_some() { + let f = str_param(p, "format").filter(|f| DATE_FORMATS.contains(f)); + if f.is_none() { + return Err(bad("insert.dateTime", "`format` must be datetime1..datetime13")); + } + } + if p.get("update").is_some() && bool_param(p, "update").is_none() { + return Err(bad("insert.dateTime", "`update` must be true or false")); + } + let lang = if p.get("language").is_some() { + let raw = str_param(p, "language").filter(|l| !l.trim().is_empty()); + let Some(raw) = raw else { + return Err(bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE")); + }; + Some(canon_lang(raw).ok_or_else(|| bad("insert.dateTime", "`language` must be en-US, en-GB, fr-FR, ja-JP or sv-SE"))?.to_string()) + } else { + None + }; + let format = str_param(p, "format").unwrap_or("datetime1").to_string(); + let sample = date_sample(&format, lang.as_deref())?; + Ok(DateOpts { format, update: bool_or(p, "update", true), lang, sample }) +} + +/// The text `format` shows now in `lang`. Errors when that language has no names for the format +/// (month names and AM/PM are English only). An empty string where there is no clock (wasm). +fn date_sample(format: &str, lang: Option<&str>) -> Result { + use deckcraft_text::datetime::{DateTime, field_text}; + let probe = DateTime::new(2026, 10, 9, 20, 5, 7); + if field_text(format, lang, probe).is_none() { + return Err(bad("insert.dateTime", "this format isn't available in that language")); + } + Ok(DateTime::now_local().and_then(|now| field_text(format, lang, now)).unwrap_or_default()) +} + +pub(super) fn date_time(s: &mut Session, p: &Value) -> Result { + if !date_time_apply(p) { + s.ui_requests.push(crate::UiRequest::Dialog { id: "dateTime".into(), params: json!({}) }); + return ok(); + } + let opts = date_opts(p)?; + let (doc, sel) = { + let st = s.doc()?; + (st.doc.clone(), st.selection.clone()) + }; + let r = apply_date(s, &opts); + if r.is_err() + && let Some(st) = s.active_mut() + { + st.doc = doc; + st.selection = sel; + } + r +} + +fn apply_date(s: &mut Session, o: &DateOpts) -> Result { + if s.doc()?.selection.text.is_some() { + if o.update { + crate::cmd::text::insert_field(s, &o.format, &o.sample)?; + if let Some(lang) = &o.lang { + stamp_caret_lang(s, lang)?; + } + } else { + insert_plain(s, &o.sample, o.lang.as_deref())?; + } + return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample})); + } + // Not editing: the slide's date placeholder (this slide only). A layout with no date + // placeholder gets a text box instead, so the chosen format is still inserted. + s.execute("design.headerFooter", &json!({"date": true, "all": false}))?; + if !write_date_placeholder(s, o)? { + return insert_date_box(s, o); + } + Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample})) +} + +/// Point the current slide's date placeholder at this format (a field, or the sample as plain text). +fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result { + let format = o.format.clone(); + let sample = o.sample.clone(); + let lang = o.lang.clone(); + let update = o.update; + s.edit(|doc, sel| { + let Some(sl) = doc.slides.get_mut(sel.slide) else { return Ok(false) }; + let sl = std::sync::Arc::make_mut(sl); + let Some(sh) = sl.shapes.iter_mut().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)) else { + return Ok(false); + }; + let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default(); + if let Some(lang) = lang { + props.lang = Some(lang); + } + let kind = if update { RunKind::Field { field: format } } else { RunKind::Text }; + let para = deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run { text: sample, props, kind }], ..Default::default() }; + let body = sh.text.clone().unwrap_or_default(); + sh.text = Some(TextBody { paragraphs: vec![para], ..body }); + Ok(true) + }) +} + +fn insert_date_box(s: &mut Session, o: &DateOpts) -> Result { + let size = s.doc()?.doc.slide_size; + let y = (size.height - 48.0).max(0.0); + s.execute("insert.textBox", &json!({"rect": [36.0, y, 360.0, 28.0], "text": ""}))?; + if o.update { + crate::cmd::text::insert_field(s, &o.format, &o.sample)?; + } else { + crate::cmd::text::insert_text(s, &o.sample, None)?; + } + if let Some(lang) = &o.lang { + stamp_caret_lang(s, lang)?; + } + Ok(json!({"where": "textBox", "format": o.format, "update": o.update, "text": o.sample})) +} + +/// Insert `text` at the caret. `lang`, when set, is on that run before runs with equal props merge, +/// so the date stays its own run next to the surrounding text. +fn insert_plain(s: &mut Session, text: &str, lang: Option<&str>) -> Result<()> { + let text = text.to_string(); + let lang = lang.map(str::to_string); + crate::cmd::text::edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = deckcraft_model::edit::delete(body, a, b); + let mut props = deckcraft_model::edit::props_at(body, at); + if let Some(lang) = lang.clone() { + props.lang = Some(lang); + } + let c = deckcraft_model::edit::insert(body, at, &text, Some(props)); + t.anchor = c; + t.caret = c; + })?; + Ok(()) +} + +/// Tag the run that ends at the caret with `lang`, so a date field keeps that language when it updates. +fn stamp_caret_lang(s: &mut Session, lang: &str) -> Result<()> { + let lang = lang.to_string(); + crate::cmd::text::edit_text(s, move |body, t| { + let Some(p) = body.paragraphs.get_mut(t.caret.0) else { return }; + let mut at = 0usize; + for r in &mut p.runs { + let n = r.char_len(); + if n > 0 && at < t.caret.1 && t.caret.1 <= at + n { + r.props.lang = Some(lang); + return; + } + at += n; + } + })?; + Ok(()) +} + +pub(super) fn symbol(s: &mut Session, p: &Value) -> Result { + let t = str_param(p, "text").ok_or_else(|| bad("insert.symbol", "missing `text`"))?.to_string(); + if s.doc()?.selection.text.is_none() { + return Err(bad("insert.symbol", "place the insertion point in text first")); + } + crate::cmd::text::insert_text(s, &t, None) +} diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index f2a5da07a..f3d41640c 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -14,6 +14,19 @@ use super::{ }; use crate::{Result, Session}; +mod appearance; +mod freeform; +mod picture; +mod size; + +pub(crate) use appearance::gradient_param; +pub use appearance::quick_style_for; +use appearance::{adjust, change, effects, fill, line, picture_props, quick_style, smooth_corners}; +pub use freeform::freeform_path; +use freeform::{connect, freeform, ink_to_shape, sites}; +use picture::{crop, media_options, picture_adjust, picture_change, picture_reset}; +use size::{clamp_pos, clamp_side, flip, rotate, size_position}; + pub fn specs() -> Vec { vec![ cmd!( @@ -174,17 +187,6 @@ pub fn specs() -> Vec { ] } -/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering. -/// An overflow to infinity lands on the limit; NaN becomes 0. -fn clamp_side(v: f64) -> f64 { - if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) } -} - -/// Keeps a coordinate within `±POS_LIMIT`. -fn clamp_pos(v: f64) -> f64 { - if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) } -} - 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")); @@ -242,320 +244,6 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { }) } -const SIZE_POS: &str = "shape.sizePosition"; -/// Smallest and largest side the dialog will write, in points (220 in). -const MIN_PT: f64 = 0.01; -const MAX_PT: f64 = 15_840.0; -const POS_LIMIT: f64 = 100_000.0; -const MAX_SCALE: f64 = 10_000.0; - -#[derive(Clone, Copy)] -struct SizeSpec { - x: Option, - y: Option, - w: Option, - h: Option, - rot: Option, - scale_w: Option, - scale_h: Option, - lock: bool, -} - -impl SizeSpec { - fn width_set(self) -> bool { - self.w.is_some() || self.scale_w.is_some() - } - fn height_set(self) -> bool { - self.h.is_some() || self.scale_h.is_some() - } - fn any(self) -> bool { - self.x.is_some() || self.y.is_some() || self.width_set() || self.height_set() || self.rot.is_some() - } -} - -fn finite_key(p: &Value, key: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => match v.as_f64() { - Some(n) if n.is_finite() => Ok(Some(n)), - _ => Err(bad(SIZE_POS, format!("`{key}` must be a finite number"))), - }, - } -} - -fn in_range(v: Option, key: &str, lo: f64, hi: f64) -> Result> { - if let Some(n) = v - && !(lo..=hi).contains(&n) - { - return Err(bad(SIZE_POS, format!("`{key}` is out of range"))); - } - Ok(v) -} - -fn size_spec(p: &Value) -> Result { - let lock = match p.get("lockAspect") { - None | Some(Value::Null) => false, - Some(Value::Bool(b)) => *b, - Some(_) => return Err(bad(SIZE_POS, "`lockAspect` must be true or false")), - }; - Ok(SizeSpec { - x: in_range(finite_key(p, "x")?, "x", -POS_LIMIT, POS_LIMIT)?, - y: in_range(finite_key(p, "y")?, "y", -POS_LIMIT, POS_LIMIT)?, - w: in_range(finite_key(p, "w")?, "w", MIN_PT, MAX_PT)?, - h: in_range(finite_key(p, "h")?, "h", MIN_PT, MAX_PT)?, - rot: finite_key(p, "rot")?, - scale_w: in_range(finite_key(p, "scaleW")?, "scaleW", f64::MIN_POSITIVE, MAX_SCALE)?, - scale_h: in_range(finite_key(p, "scaleH")?, "scaleH", f64::MIN_POSITIVE, MAX_SCALE)?, - lock, - }) -} - -fn norm_deg(r: f64) -> f64 { - let r = r % 360.0; - if r < 0.0 { r + 360.0 } else { r } -} - -/// Width wins when both sides are set with the aspect locked, matching `shape.resize`. -fn apply_size(x: &mut deckcraft_geom::Xfrm, spec: SizeSpec) { - let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 }; - let mut nw = x.w; - let mut nh = x.h; - if let Some(p) = spec.scale_w { - nw = x.w * p / 100.0; - } - if let Some(p) = spec.scale_h { - nh = x.h * p / 100.0; - } - if let Some(w) = spec.w { - nw = w; - } - if let Some(h) = spec.h { - nh = h; - } - if spec.lock && (spec.width_set() || spec.height_set()) { - if spec.width_set() { - nh = if ar > 0.0 { nw / ar } else { nh }; - } else { - nw = nh * ar; - } - } - x.w = nw.clamp(MIN_PT, MAX_PT); - x.h = nh.clamp(MIN_PT, MAX_PT); - if let Some(px) = spec.x { - x.x = px; - } - if let Some(py) = spec.y { - x.y = py; - } - if let Some(r) = spec.rot { - x.rot = norm_deg(r); - } -} - -fn size_position(s: &mut Session, p: &Value) -> Result { - let spec = size_spec(p)?; - if !spec.any() { - let (x, y, w, h, rot) = { - let st = s.doc()?; - let id = targets(s, p)?.first().copied().ok_or_else(|| bad(SIZE_POS, "nothing selected"))?; - let sh = st.shape(id).ok_or_else(|| bad(SIZE_POS, "no such shape"))?; - let x = xfrm_of(&st.doc, &st.selection, sh); - (x.x, x.y, x.w, x.h, x.rot) - }; - s.ui_requests.push(crate::UiRequest::Dialog { - id: "sizePosition".into(), - params: json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "scaleW": 100.0, "scaleH": 100.0, "lockAspect": false}), - }); - return Ok(Value::Null); - } - let changed = { - let ids = targets(s, p)?; - let st = s.doc()?; - let free: Vec<_> = ids.iter().copied().filter(|id| !st.shape(*id).is_some_and(|sh| sh.locked)).collect::>(); - if !ids.is_empty() && free.is_empty() { - return Err(bad(SIZE_POS, "the object is locked")); - } - free.iter().any(|id| { - st.shape(*id).is_some_and(|sh| { - let mut x = xfrm_of(&st.doc, &st.selection, sh); - let before = x; - apply_size(&mut x, spec); - x != before - }) - }) - }; - if !changed { - return Ok(Value::Null); - } - edit_geometry(s, p, SIZE_POS, |sh| { - if let Some(x) = sh.xfrm.as_mut() { - apply_size(x, spec); - } - Ok(()) - }) -} - -fn rotate(s: &mut Session, p: &Value) -> Result { - // A trackpad twist sends many small steps. `gesture: move` keeps them as one undo; - // `gesture: end` commits that step and does not rotate again. - if p.get("gesture").and_then(Value::as_str) == Some("end") { - s.commit_named("Rotation"); - return Ok(json!({})); - } - if p.get("gesture").and_then(Value::as_str) == Some("move") { - s.begin("Rotation"); - } - let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); - edit_geometry(s, p, "shape.rotate", |sh| { - if let Some(x) = sh.xfrm.as_mut() { - 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_geometry(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; - } - // `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation. - x.rot = (360.0 - x.rot_norm()) % 360.0; - } - 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_geometry(s, p, "shape.adjust", |sh| { - if let Geom::Preset { name, adj } = &mut sh.geom { - let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); - while adj.len() <= i { - let k = adj.len(); - adj.push(defaults.get(k).copied().unwrap_or(0.0)); - } - if let Some(slot) = adj.get_mut(i) { - *slot = v; - } - } - Ok(()) - }) -} - -/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed -/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says. -fn smooth_corners(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "shape.smoothCorners"; - let smoothing = - f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0; - let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0)); - edit_geometry(s, p, CMD, |sh| { - let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) }; - let ss = x.w.min(x.h).max(0.0); - let own = match &sh.geom { - Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0), - Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), - _ => None, - }; - // A custom outline qualifies only when it is a squircle this command drew with `radius` - // (re-smoothing as the slider moves), never an arbitrary freeform. - let custom = match (&sh.geom, radius) { - (Geom::Custom { paths }, Some(r)) => match paths.as_slice() { - [only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d), - _ => false, - }, - _ => false, - }; - let r = match (radius, own) { - (Some(r), _) if own.is_some() || custom => r, - (None, Some(r)) => r, - _ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")), - }; - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { - w: x.w, - h: x.h, - d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing), - ..Default::default() - }], - }; - Ok(()) - }) -} - -fn change(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; - let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; - edit_geometry(s, p, "shape.change", |sh| { - sh.geom = Geom::preset(info.name); - Ok(()) - }) -} - -/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear), -/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits), -/// rotateWithShape?}`. -pub(crate) fn gradient_param(v: &Value) -> Option { - 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 focus = v - .get("focus") - .and_then(Value::as_array) - .and_then(|a| { - let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0)); - Some([f(0)?, f(1)?, f(2)?, f(3)?]) - }) - .unwrap_or([0.5, 0.5, 0.5, 0.5]); - let mut stops = stops; - stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); - let path = |p: &str| GradientShape::Path { path: p.into(), focus }; - let shape = match v.get("kind").and_then(Value::as_str) { - Some("radial" | "circle") => path("circle"), - Some("rectangular" | "rect") => path("rect"), - Some("path" | "shape") => path("shape"), - _ => GradientShape::Linear { - angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0), - scaled: false, - }, - }; - Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) -} - -/// Fill and outline apply to a group's visible members as well as its container. Other edits -/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit. -fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result { - fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> { - if depth > 64 { - return Ok(()); - } - f(sh)?; - if let Some(children) = sh.children_mut() { - for child in children { - apply(child, depth + 1, f)?; - } - } - Ok(()) - } - edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f)) -} - /// The fill a `shape.fill` request sets: `Some(None)` clears it, `Some(Some(_))` sets it, and `None` /// leaves it as it is. fn requested_fill(s: &mut Session, p: &Value) -> Result>> { @@ -642,391 +330,6 @@ fn apply_fill( Ok(()) } -fn fill(s: &mut Session, p: &Value) -> Result { - let new_fill = requested_fill(s, p)?; - let transparency = f64_param(p, "transparency"); - let pic_props = p.get("pictureProps").filter(|v| v.is_object()); - if new_fill.is_none() && transparency.is_none() && pic_props.is_none() { - return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`")); - } - // Transparency on an inherited (style) fill needs the effective colour. - let inherited = inherited_fills(s, p)?; - edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)) -} - -/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. -fn picture_props(pf: &mut PictureFill, pp: &Value) { - let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); - if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { - pf.upright = !up; - } - match pp.get("tile").and_then(Value::as_bool) { - Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { - pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; - } - Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), - _ => {} - } - match &mut pf.mode { - PictureMode::Tile { tx, ty, sx, sy, flip, align } => { - if let Some(v) = f("offsetX") { - *tx = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("offsetY") { - *ty = v.clamp(-4000.0, 4000.0); - } - if let Some(v) = f("scaleX") { - *sx = v.clamp(0.01, 10.0); - } - if let Some(v) = f("scaleY") { - *sy = v.clamp(0.01, 10.0); - } - if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { - *flip = m.to_string(); - } - if let Some(a) = - pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) - { - *align = a.to_string(); - } - } - PictureMode::Stretch { fill_rect } => { - if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { - for (slot, v) in fill_rect.iter_mut().zip(arr) { - if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { - *slot = v.clamp(-10.0, 0.99); - } - } - } - } - } -} - -fn line_end(v: &str) -> Option { - if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } -} - -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" => deckcraft_model::style::LineCap::Round, - "square" => deckcraft_model::style::LineCap::Square, - _ => deckcraft_model::style::LineCap::Flat, - }); - let compound = str_param(p, "compound").map(|c| match c { - "double" => deckcraft_model::style::Compound::Double, - "thickThin" => deckcraft_model::style::Compound::ThickThin, - "thinThick" => deckcraft_model::style::Compound::ThinThick, - "triple" => deckcraft_model::style::Compound::Triple, - _ => deckcraft_model::style::Compound::Single, - }); - let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string); - let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize")); - let join = str_param(p, "join").map(|c| match c { - "bevel" => deckcraft_model::style::LineJoin::Bevel, - "miter" => deckcraft_model::style::LineJoin::Miter, - _ => deckcraft_model::style::LineJoin::Round, - }); - edit_group_appearance(s, p, "shape.line", |sh| { - if reset { - sh.line = None; - return Ok(()); - } - let l = sh.line.get_or_insert_with(Line::default); - if none { - l.fill = Some(Fill::None); - } - if let Some(c) = &color { - l.fill = Some(Fill::solid(c.clone())); - } - if width.is_some() { - l.width = width; - if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none { - l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))); - } - } - if dash.is_some() { - l.dash.clone_from(&dash); - } - let keep_size = |old: &Option, new: &Option| { - new.clone().map(|mut n| { - if let Some(o) = old { - n.w.clone_from(&o.w); - n.len.clone_from(&o.len); - } - n - }) - }; - if let Some(h) = &head { - l.head = keep_size(&l.head, h); - } - if let Some(t) = &tail { - l.tail = keep_size(&l.tail, t); - } - if cap.is_some() { - l.cap = cap; - } - if join.is_some() { - l.join = join; - } - if compound.is_some() { - l.compound = compound; - } - // Arrowhead size (width and length together), kept when the kind changes. - for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] { - if let (Some(e), Some(sz)) = (end.as_mut(), size) { - e.w.clone_from(sz); - e.len.clone_from(sz); - } - } - Ok(()) - }) -} - -fn shadow_preset(v: &Value) -> Option> { - 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 three_d = p.get("threeD").filter(|v| v.is_object()); - let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) }); - let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned(); - let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned(); - let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned(); - edit_shapes(s, p, "shape.effects", |sh| { - if reset { - sh.effects = None; - return Ok(()); - } - let e = sh.effects.get_or_insert_with(Effects::default); - if let Some(sw) = &shadow { - match sw { - Some(x) if x.inner => { - e.inner_shadow = Some(x.clone()); - e.outer_shadow = None; - } - Some(x) => { - e.outer_shadow = Some(x.clone()); - e.inner_shadow = None; - } - None => { - e.outer_shadow = None; - e.inner_shadow = None; - } - } - } - if let Some(g) = &glow { - e.glow.clone_from(g); - } - if let Some(sf) = soft { - e.soft_edge = sf; - } - if let Some(r) = &refl { - e.reflection.clone_from(r); - } - if bevel.is_some() || three_d.is_some() { - let mut s3 = e.raw3d.as_deref().map(super::three_d::Shape3d::parse).unwrap_or_default(); - if let Some(b) = &bevel { - s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")})); - } - if let Some(t) = three_d { - if t.get("reset").and_then(Value::as_bool) == Some(true) { - s3 = super::three_d::Shape3d::default(); - } - s3.apply(t); - } - super::three_d::store(e, &s3); - } - if let Some(v) = &shadow_props { - let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow }; - let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?; - if let Some(c) = v.get("color").and_then(color_value) { - let a = x.color.alpha(); - x.color = with_alpha(c, a); - } - if let Some(t) = num(v, "transparency") { - x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0)); - } - if let Some(b) = num(v, "blur") { - x.blur = b.clamp(0.0, 100.0); - } - if let Some(d) = num(v, "dist") { - x.dist = d.clamp(0.0, 200.0); - } - if let Some(d) = num(v, "dir") { - x.dir = d.rem_euclid(360.0); - } - *slot = Some(x); - } - if let Some(v) = &glow_props { - let mut g = - e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 }); - if let Some(c) = v.get("color").and_then(color_value) { - let a = g.color.alpha(); - g.color = with_alpha(c, a); - } - if let Some(t) = num(v, "transparency") { - g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0)); - } - if let Some(r) = num(v, "radius") { - g.radius = r.clamp(0.0, 150.0); - } - e.glow = (g.radius > 0.0).then_some(g); - } - if let Some(v) = &refl_props { - let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }); - if let Some(t) = num(v, "transparency") { - r.start_alpha = 1.0 - t.clamp(0.0, 1.0); - } - if let Some(sz) = num(v, "size") { - r.end_pos = sz.clamp(0.0, 1.0); - } - if let Some(d) = num(v, "dist") { - r.dist = d.clamp(0.0, 100.0); - } - if let Some(b) = num(v, "blur") { - r.blur = b.clamp(0.0, 100.0); - } - e.reflection = Some(r); - } - Ok(()) - }) -} - -/// A finite number at `key` of an effect's properties. -fn num(v: &Value, key: &str) -> Option { - v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) -} - -/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform. -fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef { - c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); - if a < 1.0 { - c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32)); - } - c -} - -/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents. -pub fn quick_style_for(index: usize) -> (ShapeStyle, Option, Option) { - let slots = [ - 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() { - deckcraft_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| { @@ -1094,451 +397,6 @@ fn set_default(s: &mut Session, _p: &Value) -> Result { 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_geometry(s, p, "picture.crop", |sh| { - if let deckcraft_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 deckcraft_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 deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { - fill.adjust = Default::default(); - fill.crop = [0.0; 4]; - fill.alpha = None; - if let Some(a) = fill.artistic.take() { - fill.media = a.original; - } - } - sh.effects = None; - sh.line = None; - 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, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?; - edit_shapes(s, p, "picture.change", |sh| { - if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { - fill.media = media; - fill.crop = [0.0; 4]; - fill.artistic = None; - } - Ok(()) - }) -} - -fn media_options(s: &mut Session, p: &Value) -> Result { - edit_shapes(s, p, "media.options", |sh| { - if let deckcraft_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!({})) -} - -fn sites(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?; - let st = s.doc()?; - let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?; - Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::>())) -} - -fn connect(s: &mut Session, p: &Value) -> Result { - let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else { - return Err(bad("shape.connect", "missing `from` / `to`")); - }; - let st = s.doc()?; - let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default(); - let (a, b) = (site_list(from), site_list(to)); - if a.is_empty() || b.is_empty() { - return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)")); - } - let pick = |v: &[deckcraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len()); - let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) { - (Some(i), Some(j)) => (i, j), - (fi, fj) => { - let mut best = (f64::MAX, 0, 0); - for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) { - for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) { - let d = (*pa - *pb).hypot(); - if d < best.0 { - best = (d, i, j); - } - } - } - (best.1, best.2) - } - }; - let (p0, p1) = (a[ia], b[ib]); - let id = match id_param(p, "id", "shape.connect")? { - Some(id) => id, - None => { - let preset = str_param(p, "preset").unwrap_or("straightConnector1"); - let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?; - deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32) - } - }; - s.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?; - let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?; - if !sh.is_line() { - return Err(bad("shape.connect", "`id` is not a line or connector")); - } - sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0)); - sh.kind = deckcraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) }; - Ok(()) - })?; - Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib})) -} - -/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics. -pub fn freeform_path(pts: &[deckcraft_geom::Point], closed: bool, smooth: bool) -> String { - let f = |v: f64| { - let s = format!("{:.2}", v); - s.trim_end_matches('0').trim_end_matches('.').to_string() - }; - let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y)); - let n = pts.len(); - if smooth && n > 2 { - let at = |i: isize| -> deckcraft_geom::Point { - if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] } - }; - let segs = if closed { n } else { n - 1 }; - for i in 0..segs as isize { - let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); - let c1 = p1 + (p2 - p0) / 6.0; - let c2 = p2 - (p3 - p1) / 6.0; - d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y)); - } - } else { - for q in &pts[1..] { - d += &format!(" L {} {}", f(q.x), f(q.y)); - } - } - if closed { - d += " Z"; - } - d -} - -fn freeform(s: &mut Session, p: &Value) -> Result { - let pts: Vec = p - .get("points") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) - .filter(|q| q.x.is_finite() && q.y.is_finite()) - .take(20_000) - .collect() - }) - .unwrap_or_default(); - if pts.len() < 2 { - return Err(bad("shape.freeform", "need at least two points")); - } - let closed = bool_or(p, "closed", false) && pts.len() > 2; - let smooth = bool_or(p, "smooth", false); - let mut bb = deckcraft_geom::Rect::from_points(pts[0], pts[0]); - for q in &pts { - bb = bb.union_pt(*q); - } - let (w, h) = (bb.width().max(1.0), bb.height().max(1.0)); - let local: Vec = pts.iter().map(|q| deckcraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); - let d = freeform_path(&local, closed, smooth); - // Closed shapes take the default shape style, open paths the line style. - let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?; - let id = deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32); - s.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?; - let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?; - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { - w, - h, - d: d.clone(), - fill: if closed { deckcraft_geom::preset::FillMode::Norm } else { deckcraft_geom::preset::FillMode::None }, - stroke: true, - }], - }; - sh.xfrm = Some(deckcraft_geom::Xfrm::new(bb.x0, bb.y0, w, h)); - sh.kind = deckcraft_model::ShapeKind::Shape; - sh.name = format!("Freeform {}", id.0); - Ok(()) - })?; - Ok(json!({"id": id.0})) -} - -#[cfg(test)] -mod size_position_tests { - use serde_json::json; - - use crate::Session; - - fn deck() -> (Session, u32) { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); - let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32; - (s, id) - } - - fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) { - let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); - let b = &v["box"]; - (b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0)) - } - - fn undos(s: &Session) -> usize { - s.doc().unwrap().history.undo.len() - } - - #[test] - fn sets_position_size_and_rotation_in_one_undo() { - let (mut s, id) = deck(); - let n = undos(&s); - s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap(); - assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0)); - assert_eq!(undos(&s), n + 1); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); - } - - #[test] - fn lock_aspect_and_scale_share_one_step() { - let (mut s, id) = deck(); - s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0)); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0)); - // An explicit width wins over a scale on the same axis. - s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap(); - assert_eq!(geom(&mut s, id).2, 80.0); - } - - #[test] - fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() { - let (mut s, id) = deck(); - let n = undos(&s); - s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0)); - assert_eq!(undos(&s), n + 1); - let n = undos(&s); - s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap(); - assert_eq!(undos(&s), n, "identical geometry is not an undo step"); - } - - #[test] - fn no_fields_opens_the_dialog_without_editing() { - let (mut s, id) = deck(); - let n = undos(&s); - s.execute("shape.sizePosition", &json!({})).unwrap(); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); - assert_eq!(undos(&s), n); - let req = s.ui_requests.last().unwrap(); - match req { - crate::UiRequest::Dialog { id, params } => { - assert_eq!(id, "sizePosition"); - assert_eq!(params["w"], 200.0); - assert_eq!(params["h"], 100.0); - assert_eq!(params["x"], 40.0); - assert_eq!(params["rot"], 0.0); - assert_eq!(params["scaleW"], 100.0); - assert_eq!(params["lockAspect"], false); - } - other => panic!("expected a dialog, got {other:?}"), - } - } - - #[test] - fn bad_fields_do_not_edit() { - let (mut s, id) = deck(); - let n = undos(&s); - for p in [ - json!({"w": "wide"}), - json!({"h": -5.0}), - json!({"scaleW": 0.0}), - json!({"x": 1e9}), - json!({"rot": "spin"}), - json!({"lockAspect": "yes"}), - ] { - let err = s.execute("shape.sizePosition", &p).unwrap_err(); - assert!(err.to_string().contains("sizePosition"), "{err}"); - assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); - assert_eq!(undos(&s), n); - } - } - - #[test] - fn scale_is_per_shape_and_nothing_selected_is_disabled() { - let (mut s, a) = deck(); - let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; - s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap(); - assert_eq!(geom(&mut s, a).2, 400.0); - assert_eq!(geom(&mut s, a).3, 100.0); - assert_eq!(geom(&mut s, b).2, 80.0); - assert_eq!(geom(&mut s, b).3, 80.0); - assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…"); - s.execute("edit.select", &json!({"ids": []})).unwrap(); - let err = s.execute("shape.sizePosition", &json!({})).unwrap_err(); - assert!(err.to_string().contains("nothing selected"), "{err}"); - } -} - -/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape -/// outlined in the stroke's colour and width; the rest stays ink. One undo step. -fn ink_to_shape(s: &mut Session, _p: &Value) -> Result { - use crate::inkshape::{Recognized, recognize}; - use deckcraft_model::{Shape, ShapeId, ShapeKind}; - let shapes = s.doc()?.shapes(); - let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else { - return Ok(json!({"shapes": [], "kept": 0})); - }; - let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0))); - let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) }; - let found: Vec> = strokes - .iter() - .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::>()) }) - .collect(); - if found.iter().all(Option::is_none) { - return Ok(json!({"shapes": [], "kept": strokes.len()})); - } - s.edit(|doc, sel| { - let mut new = vec![]; - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; - let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) }; - let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) }; - let all = std::mem::take(strokes); - let mut kept = vec![]; - let mut made = vec![]; - for (st, r) in all.into_iter().zip(found.iter()) { - let Some(r) = r else { - kept.push(st); - continue; - }; - let (preset, xfrm) = match *r { - Recognized::Line { x0, y0, x1, y1 } => { - ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0)) - } - Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)), - Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)), - Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }), - }; - made.push((preset, xfrm, st.color, st.width)); - } - let remaining = kept.len(); - *strokes = kept; - if remaining == 0 { - list.remove(pos); - } - for (preset, xfrm, color, width) in made { - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("draw.inkToShape", NO_ID_SPACE))?); - let name = format!( - "{} {}", - match preset { - "line" => "Straight Connector", - "rect" => "Rectangle", - "ellipse" => "Oval", - _ => "Isosceles Triangle", - }, - id.0 - ); - let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; - list.push(Shape { - id, - name, - xfrm: Some(xfrm), - geom: Geom::preset(preset), - fill: Some(Fill::None), - line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))), - kind: ShapeKind::Shape, - ..Default::default() - }); - new.push(id); - } - sel.shapes = new.clone(); - Ok(json!({"shapes": new.iter().map(|i| i.0).collect::>(), "kept": remaining})) - }) -} - #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs new file mode 100644 index 000000000..7adb642c1 --- /dev/null +++ b/crates/engine/src/cmd/shape/appearance.rs @@ -0,0 +1,515 @@ +use super::{ + ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode, + Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry, + edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, usize_param, +}; + +pub(super) fn adjust(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").unwrap_or(0).min(16); + let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?; + edit_geometry(s, p, "shape.adjust", |sh| { + if let Geom::Preset { name, adj } = &mut sh.geom { + let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]); + while adj.len() <= i { + let k = adj.len(); + adj.push(defaults.get(k).copied().unwrap_or(0.0)); + } + if let Some(slot) = adj.get_mut(i) { + *slot = v; + } + } + Ok(()) + }) +} + +/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed +/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says. +pub(super) fn smooth_corners(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "shape.smoothCorners"; + let smoothing = + f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0; + let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0)); + edit_geometry(s, p, CMD, |sh| { + let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) }; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0), + Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + // A custom outline qualifies only when it is a squircle this command drew with `radius` + // (re-smoothing as the slider moves), never an arbitrary freeform. + let custom = match (&sh.geom, radius) { + (Geom::Custom { paths }, Some(r)) => match paths.as_slice() { + [only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d), + _ => false, + }, + _ => false, + }; + let r = match (radius, own) { + (Some(r), _) if own.is_some() || custom => r, + (None, Some(r)) => r, + _ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")), + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w: x.w, + h: x.h, + d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing), + ..Default::default() + }], + }; + Ok(()) + }) +} + +pub(super) fn change(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?; + let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?; + edit_geometry(s, p, "shape.change", |sh| { + sh.geom = Geom::preset(info.name); + Ok(()) + }) +} + +/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear), +/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits), +/// rotateWithShape?}`. +pub(crate) fn gradient_param(v: &Value) -> Option { + 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 focus = v + .get("focus") + .and_then(Value::as_array) + .and_then(|a| { + let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0)); + Some([f(0)?, f(1)?, f(2)?, f(3)?]) + }) + .unwrap_or([0.5, 0.5, 0.5, 0.5]); + let mut stops = stops; + stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); + let path = |p: &str| GradientShape::Path { path: p.into(), focus }; + let shape = match v.get("kind").and_then(Value::as_str) { + Some("radial" | "circle") => path("circle"), + Some("rectangular" | "rect") => path("rect"), + Some("path" | "shape") => path("shape"), + _ => GradientShape::Linear { + angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0), + scaled: false, + }, + }; + Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) }) +} + +/// Fill and outline apply to a group's visible members as well as its container. Other edits +/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit. +fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result { + fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> { + if depth > 64 { + return Ok(()); + } + f(sh)?; + if let Some(children) = sh.children_mut() { + for child in children { + apply(child, depth + 1, f)?; + } + } + Ok(()) + } + edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f)) +} + +pub(super) fn fill(s: &mut Session, p: &Value) -> Result { + let new_fill = requested_fill(s, p)?; + let transparency = f64_param(p, "transparency"); + let pic_props = p.get("pictureProps").filter(|v| v.is_object()); + if new_fill.is_none() && transparency.is_none() && pic_props.is_none() { + return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`")); + } + // Transparency on an inherited (style) fill needs the effective colour. + let inherited = inherited_fills(s, p)?; + edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)) +} + +/// Applies Format Shape ▸ Picture or texture fill options to a picture fill. +pub(super) fn picture_props(pf: &mut PictureFill, pp: &Value) { + let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) { + pf.upright = !up; + } + match pp.get("tile").and_then(Value::as_bool) { + Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => { + pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() }; + } + Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(), + _ => {} + } + match &mut pf.mode { + PictureMode::Tile { tx, ty, sx, sy, flip, align } => { + if let Some(v) = f("offsetX") { + *tx = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("offsetY") { + *ty = v.clamp(-4000.0, 4000.0); + } + if let Some(v) = f("scaleX") { + *sx = v.clamp(0.01, 10.0); + } + if let Some(v) = f("scaleY") { + *sy = v.clamp(0.01, 10.0); + } + if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) { + *flip = m.to_string(); + } + if let Some(a) = + pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) + { + *align = a.to_string(); + } + } + PictureMode::Stretch { fill_rect } => { + if let Some(arr) = pp.get("stretch").and_then(Value::as_array) { + for (slot, v) in fill_rect.iter_mut().zip(arr) { + if let Some(v) = v.as_f64().filter(|v| v.is_finite()) { + *slot = v.clamp(-10.0, 0.99); + } + } + } + } + } +} + +fn line_end(v: &str) -> Option { + if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) } +} + +pub(super) fn line(s: &mut Session, p: &Value) -> Result { + let reset = bool_or(p, "reset", false); + let none = bool_or(p, "none", false); + let color = color_param(p, "color"); + let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0)); + let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() }); + let head = str_param(p, "head").map(line_end); + let tail = str_param(p, "tail").map(line_end); + let cap = str_param(p, "cap").map(|c| match c { + "round" => deckcraft_model::style::LineCap::Round, + "square" => deckcraft_model::style::LineCap::Square, + _ => deckcraft_model::style::LineCap::Flat, + }); + let compound = str_param(p, "compound").map(|c| match c { + "double" => deckcraft_model::style::Compound::Double, + "thickThin" => deckcraft_model::style::Compound::ThickThin, + "thinThick" => deckcraft_model::style::Compound::ThinThick, + "triple" => deckcraft_model::style::Compound::Triple, + _ => deckcraft_model::style::Compound::Single, + }); + let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string); + let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize")); + let join = str_param(p, "join").map(|c| match c { + "bevel" => deckcraft_model::style::LineJoin::Bevel, + "miter" => deckcraft_model::style::LineJoin::Miter, + _ => deckcraft_model::style::LineJoin::Round, + }); + edit_group_appearance(s, p, "shape.line", |sh| { + if reset { + sh.line = None; + return Ok(()); + } + let l = sh.line.get_or_insert_with(Line::default); + if none { + l.fill = Some(Fill::None); + } + if let Some(c) = &color { + l.fill = Some(Fill::solid(c.clone())); + } + if width.is_some() { + l.width = width; + if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none { + l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))); + } + } + if dash.is_some() { + l.dash.clone_from(&dash); + } + let keep_size = |old: &Option, new: &Option| { + new.clone().map(|mut n| { + if let Some(o) = old { + n.w.clone_from(&o.w); + n.len.clone_from(&o.len); + } + n + }) + }; + if let Some(h) = &head { + l.head = keep_size(&l.head, h); + } + if let Some(t) = &tail { + l.tail = keep_size(&l.tail, t); + } + if cap.is_some() { + l.cap = cap; + } + if join.is_some() { + l.join = join; + } + if compound.is_some() { + l.compound = compound; + } + // Arrowhead size (width and length together), kept when the kind changes. + for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] { + if let (Some(e), Some(sz)) = (end.as_mut(), size) { + e.w.clone_from(sz); + e.len.clone_from(sz); + } + } + Ok(()) + }) +} + +fn shadow_preset(v: &Value) -> Option> { + let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a)); + let base = |inner: bool| Shadow { + color: black(40000), + blur: 4.0, + dist: 3.0, + dir: 45.0, + inner, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "tl".into(), + rotate_with_shape: false, + }; + match v { + Value::String(s) => Some(match s.as_str() { + "none" => None, + "inner" => Some(base(true)), + "perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }), + "bottom" => Some(Shadow { dir: 90.0, ..base(false) }), + "center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }), + _ => Some(base(false)), + }), + Value::Object(o) => Some(Some(Shadow { + color: o.get("color").and_then(color_value).unwrap_or(black(40000)), + blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0), + dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0), + dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0), + inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false), + ..base(false) + })), + _ => None, + } +} + +pub(super) fn effects(s: &mut Session, p: &Value) -> Result { + 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 three_d = p.get("threeD").filter(|v| v.is_object()); + let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) }); + let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned(); + let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned(); + let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned(); + edit_shapes(s, p, "shape.effects", |sh| { + if reset { + sh.effects = None; + return Ok(()); + } + let e = sh.effects.get_or_insert_with(Effects::default); + if let Some(sw) = &shadow { + match sw { + Some(x) if x.inner => { + e.inner_shadow = Some(x.clone()); + e.outer_shadow = None; + } + Some(x) => { + e.outer_shadow = Some(x.clone()); + e.inner_shadow = None; + } + None => { + e.outer_shadow = None; + e.inner_shadow = None; + } + } + } + if let Some(g) = &glow { + e.glow.clone_from(g); + } + if let Some(sf) = soft { + e.soft_edge = sf; + } + if let Some(r) = &refl { + e.reflection.clone_from(r); + } + if bevel.is_some() || three_d.is_some() { + let mut s3 = e.raw3d.as_deref().map(crate::cmd::three_d::Shape3d::parse).unwrap_or_default(); + if let Some(b) = &bevel { + s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")})); + } + if let Some(t) = three_d { + if t.get("reset").and_then(Value::as_bool) == Some(true) { + s3 = crate::cmd::three_d::Shape3d::default(); + } + s3.apply(t); + } + crate::cmd::three_d::store(e, &s3); + } + if let Some(v) = &shadow_props { + let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow }; + let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?; + if let Some(c) = v.get("color").and_then(color_value) { + let a = x.color.alpha(); + x.color = with_alpha(c, a); + } + if let Some(t) = num(v, "transparency") { + x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0)); + } + if let Some(b) = num(v, "blur") { + x.blur = b.clamp(0.0, 100.0); + } + if let Some(d) = num(v, "dist") { + x.dist = d.clamp(0.0, 200.0); + } + if let Some(d) = num(v, "dir") { + x.dir = d.rem_euclid(360.0); + } + *slot = Some(x); + } + if let Some(v) = &glow_props { + let mut g = + e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 }); + if let Some(c) = v.get("color").and_then(color_value) { + let a = g.color.alpha(); + g.color = with_alpha(c, a); + } + if let Some(t) = num(v, "transparency") { + g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0)); + } + if let Some(r) = num(v, "radius") { + g.radius = r.clamp(0.0, 150.0); + } + e.glow = (g.radius > 0.0).then_some(g); + } + if let Some(v) = &refl_props { + let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }); + if let Some(t) = num(v, "transparency") { + r.start_alpha = 1.0 - t.clamp(0.0, 1.0); + } + if let Some(sz) = num(v, "size") { + r.end_pos = sz.clamp(0.0, 1.0); + } + if let Some(d) = num(v, "dist") { + r.dist = d.clamp(0.0, 100.0); + } + if let Some(b) = num(v, "blur") { + r.blur = b.clamp(0.0, 100.0); + } + e.reflection = Some(r); + } + Ok(()) + }) +} + +/// A finite number at `key` of an effect's properties. +fn num(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) +} + +/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform. +fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef { + c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_))); + if a < 1.0 { + c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32)); + } + c +} + +/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents. +pub fn quick_style_for(index: usize) -> (ShapeStyle, Option, Option) { + let slots = [ + SchemeSlot::Dk1, + SchemeSlot::Accent1, + SchemeSlot::Accent2, + SchemeSlot::Accent3, + SchemeSlot::Accent4, + SchemeSlot::Accent5, + SchemeSlot::Accent6, + ]; + let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1); + let row = index / 7; + let mut st = ShapeStyle::accent(slot); + let c = ColorRef::scheme(slot); + match row { + 0 => { + // Colored outline, light fill. + st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1)); + st.line_ref = (2, c); + st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1)); + (st, None, None) + } + 1 => (st, None, None), + 2 => { + st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000))); + st.line_ref = (0, c.clone()); + st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000))); + (st, None, Some(Line::none())) + } + 3 => { + st.fill_ref = (2, c); + (st, None, Some(Line::none())) + } + 4 => { + st.fill_ref = (3, c.clone()); + st.effect_ref = (3, c); + (st, None, Some(Line::none())) + } + _ => { + st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000))); + st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1)); + (st, None, None) + } + } +} + +pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result { + let i = usize_param(p, "index").unwrap_or(1).min(41); + let (st, f, l) = quick_style_for(i); + edit_shapes(s, p, "shape.quickStyle", |sh| { + sh.style = Some(st.clone()); + sh.fill.clone_from(&f); + sh.line.clone_from(&l); + sh.effects = None; + if let Some(t) = sh.text.as_mut() { + deckcraft_model::edit::format_all(t, &|r| r.fill = None); + } + Ok(()) + }) +} diff --git a/crates/engine/src/cmd/shape/freeform.rs b/crates/engine/src/cmd/shape/freeform.rs new file mode 100644 index 000000000..f3be4f4bb --- /dev/null +++ b/crates/engine/src/cmd/shape/freeform.rs @@ -0,0 +1,209 @@ +use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param}; + +pub(super) fn sites(s: &mut Session, p: &Value) -> Result { + let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?; + let st = s.doc()?; + let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?; + Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::>())) +} + +pub(super) fn connect(s: &mut Session, p: &Value) -> Result { + let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else { + return Err(bad("shape.connect", "missing `from` / `to`")); + }; + let st = s.doc()?; + let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default(); + let (a, b) = (site_list(from), site_list(to)); + if a.is_empty() || b.is_empty() { + return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)")); + } + let pick = |v: &[deckcraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len()); + let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) { + (Some(i), Some(j)) => (i, j), + (fi, fj) => { + let mut best = (f64::MAX, 0, 0); + for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) { + for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) { + let d = (*pa - *pb).hypot(); + if d < best.0 { + best = (d, i, j); + } + } + } + (best.1, best.2) + } + }; + let (p0, p1) = (a[ia], b[ib]); + let id = match id_param(p, "id", "shape.connect")? { + Some(id) => id, + None => { + let preset = str_param(p, "preset").unwrap_or("straightConnector1"); + let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?; + deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32) + } + }; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?; + if !sh.is_line() { + return Err(bad("shape.connect", "`id` is not a line or connector")); + } + sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0)); + sh.kind = deckcraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) }; + Ok(()) + })?; + Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib})) +} + +/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics. +pub fn freeform_path(pts: &[deckcraft_geom::Point], closed: bool, smooth: bool) -> String { + let f = |v: f64| { + let s = format!("{:.2}", v); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y)); + let n = pts.len(); + if smooth && n > 2 { + let at = |i: isize| -> deckcraft_geom::Point { + if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] } + }; + let segs = if closed { n } else { n - 1 }; + for i in 0..segs as isize { + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = p1 + (p2 - p0) / 6.0; + let c2 = p2 - (p3 - p1) / 6.0; + d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y)); + } + } else { + for q in &pts[1..] { + d += &format!(" L {} {}", f(q.x), f(q.y)); + } + } + if closed { + d += " Z"; + } + d +} + +pub(super) fn freeform(s: &mut Session, p: &Value) -> Result { + let pts: Vec = p + .get("points") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))) + .filter(|q| q.x.is_finite() && q.y.is_finite()) + .take(20_000) + .collect() + }) + .unwrap_or_default(); + if pts.len() < 2 { + return Err(bad("shape.freeform", "need at least two points")); + } + let closed = bool_or(p, "closed", false) && pts.len() > 2; + let smooth = bool_or(p, "smooth", false); + let mut bb = deckcraft_geom::Rect::from_points(pts[0], pts[0]); + for q in &pts { + bb = bb.union_pt(*q); + } + let (w, h) = (bb.width().max(1.0), bb.height().max(1.0)); + let local: Vec = pts.iter().map(|q| deckcraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); + let d = freeform_path(&local, closed, smooth); + // Closed shapes take the default shape style, open paths the line style. + let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?; + let id = deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32); + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?; + let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { + w, + h, + d: d.clone(), + fill: if closed { deckcraft_geom::preset::FillMode::Norm } else { deckcraft_geom::preset::FillMode::None }, + stroke: true, + }], + }; + sh.xfrm = Some(deckcraft_geom::Xfrm::new(bb.x0, bb.y0, w, h)); + sh.kind = deckcraft_model::ShapeKind::Shape; + sh.name = format!("Freeform {}", id.0); + Ok(()) + })?; + Ok(json!({"id": id.0})) +} + +/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape +/// outlined in the stroke's colour and width; the rest stays ink. One undo step. +pub(super) fn ink_to_shape(s: &mut Session, _p: &Value) -> Result { + use crate::inkshape::{Recognized, recognize}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let shapes = s.doc()?.shapes(); + let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else { + return Ok(json!({"shapes": [], "kept": 0})); + }; + let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0))); + let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) }; + let found: Vec> = strokes + .iter() + .map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::>()) }) + .collect(); + if found.iter().all(Option::is_none) { + return Ok(json!({"shapes": [], "kept": strokes.len()})); + } + s.edit(|doc, sel| { + let mut new = vec![]; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) }; + let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) }; + let all = std::mem::take(strokes); + let mut kept = vec![]; + let mut made = vec![]; + for (st, r) in all.into_iter().zip(found.iter()) { + let Some(r) = r else { + kept.push(st); + continue; + }; + let (preset, xfrm) = match *r { + Recognized::Line { x0, y0, x1, y1 } => { + ("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0)) + } + Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)), + Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }), + }; + made.push((preset, xfrm, st.color, st.width)); + } + let remaining = kept.len(); + *strokes = kept; + if remaining == 0 { + list.remove(pos); + } + for (preset, xfrm, color, width) in made { + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("draw.inkToShape", NO_ID_SPACE))?); + let name = format!( + "{} {}", + match preset { + "line" => "Straight Connector", + "rect" => "Rectangle", + "ellipse" => "Oval", + _ => "Isosceles Triangle", + }, + id.0 + ); + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?; + list.push(Shape { + id, + name, + xfrm: Some(xfrm), + geom: Geom::preset(preset), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))), + kind: ShapeKind::Shape, + ..Default::default() + }); + new.push(id); + } + sel.shapes = new.clone(); + Ok(json!({"shapes": new.iter().map(|i| i.0).collect::>(), "kept": remaining})) + }) +} diff --git a/crates/engine/src/cmd/shape/picture.rs b/crates/engine/src/cmd/shape/picture.rs new file mode 100644 index 000000000..09cf22ae0 --- /dev/null +++ b/crates/engine/src/cmd/shape/picture.rs @@ -0,0 +1,115 @@ +use super::{NO_ID_SPACE, Result, Session, Value, bad, bool_or, bool_param, edit_geometry, edit_shapes, f64_param, json}; + +pub(super) 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_geometry(s, p, "picture.crop", |sh| { + if let deckcraft_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(()) + }) +} + +pub(super) fn picture_adjust(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "picture.adjust", |sh| { + if let deckcraft_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(()) + }) +} + +pub(super) fn picture_reset(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "picture.reset", |sh| { + if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { + fill.adjust = Default::default(); + fill.crop = [0.0; 4]; + fill.alpha = None; + if let Some(a) = fill.artistic.take() { + fill.media = a.original; + } + } + sh.effects = None; + sh.line = None; + Ok(()) + }) +} + +pub(super) fn picture_change(s: &mut Session, p: &Value) -> Result { + let (name, bytes) = crate::cmd::insert::media_bytes(p, "picture.change")?; + let ct = crate::cmd::insert::content_type(&name, &bytes); + let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?; + edit_shapes(s, p, "picture.change", |sh| { + if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind { + fill.media = media; + fill.crop = [0.0; 4]; + fill.artistic = None; + } + Ok(()) + }) +} + +pub(super) fn media_options(s: &mut Session, p: &Value) -> Result { + edit_shapes(s, p, "media.options", |sh| { + if let deckcraft_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/shape/size.rs b/crates/engine/src/cmd/shape/size.rs new file mode 100644 index 000000000..af235e403 --- /dev/null +++ b/crates/engine/src/cmd/shape/size.rs @@ -0,0 +1,328 @@ +use super::{Result, Session, Value, bad, edit_geometry, f64_param, json, str_param, targets, xfrm_of}; + +/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering. +/// An overflow to infinity lands on the limit; NaN becomes 0. +pub(super) fn clamp_side(v: f64) -> f64 { + if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) } +} + +/// Keeps a coordinate within `±POS_LIMIT`. +pub(super) fn clamp_pos(v: f64) -> f64 { + if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) } +} + +const SIZE_POS: &str = "shape.sizePosition"; + +/// Smallest and largest side the dialog will write, in points (220 in). +const MIN_PT: f64 = 0.01; + +const MAX_PT: f64 = 15_840.0; + +const POS_LIMIT: f64 = 100_000.0; + +const MAX_SCALE: f64 = 10_000.0; + +#[derive(Clone, Copy)] +struct SizeSpec { + pub(crate) x: Option, + pub(crate) y: Option, + pub(crate) w: Option, + pub(crate) h: Option, + pub(crate) rot: Option, + scale_w: Option, + scale_h: Option, + lock: bool, +} + +impl SizeSpec { + fn width_set(self) -> bool { + self.w.is_some() || self.scale_w.is_some() + } + fn height_set(self) -> bool { + self.h.is_some() || self.scale_h.is_some() + } + fn any(self) -> bool { + self.x.is_some() || self.y.is_some() || self.width_set() || self.height_set() || self.rot.is_some() + } +} + +fn finite_key(p: &Value, key: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(n) if n.is_finite() => Ok(Some(n)), + _ => Err(bad(SIZE_POS, format!("`{key}` must be a finite number"))), + }, + } +} + +fn in_range(v: Option, key: &str, lo: f64, hi: f64) -> Result> { + if let Some(n) = v + && !(lo..=hi).contains(&n) + { + return Err(bad(SIZE_POS, format!("`{key}` is out of range"))); + } + Ok(v) +} + +fn size_spec(p: &Value) -> Result { + let lock = match p.get("lockAspect") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(SIZE_POS, "`lockAspect` must be true or false")), + }; + Ok(SizeSpec { + x: in_range(finite_key(p, "x")?, "x", -POS_LIMIT, POS_LIMIT)?, + y: in_range(finite_key(p, "y")?, "y", -POS_LIMIT, POS_LIMIT)?, + w: in_range(finite_key(p, "w")?, "w", MIN_PT, MAX_PT)?, + h: in_range(finite_key(p, "h")?, "h", MIN_PT, MAX_PT)?, + rot: finite_key(p, "rot")?, + scale_w: in_range(finite_key(p, "scaleW")?, "scaleW", f64::MIN_POSITIVE, MAX_SCALE)?, + scale_h: in_range(finite_key(p, "scaleH")?, "scaleH", f64::MIN_POSITIVE, MAX_SCALE)?, + lock, + }) +} + +fn norm_deg(r: f64) -> f64 { + let r = r % 360.0; + if r < 0.0 { r + 360.0 } else { r } +} + +/// Width wins when both sides are set with the aspect locked, matching `shape.resize`. +fn apply_size(x: &mut deckcraft_geom::Xfrm, spec: SizeSpec) { + let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 }; + let mut nw = x.w; + let mut nh = x.h; + if let Some(p) = spec.scale_w { + nw = x.w * p / 100.0; + } + if let Some(p) = spec.scale_h { + nh = x.h * p / 100.0; + } + if let Some(w) = spec.w { + nw = w; + } + if let Some(h) = spec.h { + nh = h; + } + if spec.lock && (spec.width_set() || spec.height_set()) { + if spec.width_set() { + nh = if ar > 0.0 { nw / ar } else { nh }; + } else { + nw = nh * ar; + } + } + x.w = nw.clamp(MIN_PT, MAX_PT); + x.h = nh.clamp(MIN_PT, MAX_PT); + if let Some(px) = spec.x { + x.x = px; + } + if let Some(py) = spec.y { + x.y = py; + } + if let Some(r) = spec.rot { + x.rot = norm_deg(r); + } +} + +pub(super) fn size_position(s: &mut Session, p: &Value) -> Result { + let spec = size_spec(p)?; + if !spec.any() { + let (x, y, w, h, rot) = { + let st = s.doc()?; + let id = targets(s, p)?.first().copied().ok_or_else(|| bad(SIZE_POS, "nothing selected"))?; + let sh = st.shape(id).ok_or_else(|| bad(SIZE_POS, "no such shape"))?; + let x = xfrm_of(&st.doc, &st.selection, sh); + (x.x, x.y, x.w, x.h, x.rot) + }; + s.ui_requests.push(crate::UiRequest::Dialog { + id: "sizePosition".into(), + params: json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "scaleW": 100.0, "scaleH": 100.0, "lockAspect": false}), + }); + return Ok(Value::Null); + } + let changed = { + let ids = targets(s, p)?; + let st = s.doc()?; + let free: Vec<_> = ids.iter().copied().filter(|id| !st.shape(*id).is_some_and(|sh| sh.locked)).collect::>(); + if !ids.is_empty() && free.is_empty() { + return Err(bad(SIZE_POS, "the object is locked")); + } + free.iter().any(|id| { + st.shape(*id).is_some_and(|sh| { + let mut x = xfrm_of(&st.doc, &st.selection, sh); + let before = x; + apply_size(&mut x, spec); + x != before + }) + }) + }; + if !changed { + return Ok(Value::Null); + } + edit_geometry(s, p, SIZE_POS, |sh| { + if let Some(x) = sh.xfrm.as_mut() { + apply_size(x, spec); + } + Ok(()) + }) +} + +pub(super) fn rotate(s: &mut Session, p: &Value) -> Result { + // A trackpad twist sends many small steps. `gesture: move` keeps them as one undo; + // `gesture: end` commits that step and does not rotate again. + if p.get("gesture").and_then(Value::as_str) == Some("end") { + s.commit_named("Rotation"); + return Ok(json!({})); + } + if p.get("gesture").and_then(Value::as_str) == Some("move") { + s.begin("Rotation"); + } + let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by")); + edit_geometry(s, p, "shape.rotate", |sh| { + if let Some(x) = sh.xfrm.as_mut() { + let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0); + x.rot = ((r % 360.0) + 360.0) % 360.0; + } + Ok(()) + }) +} + +pub(super) fn flip(s: &mut Session, p: &Value) -> Result { + let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v'); + edit_geometry(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; + } + // `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation. + x.rot = (360.0 - x.rot_norm()) % 360.0; + } + Ok(()) + }) +} + +#[cfg(test)] +mod size_position_tests { + use serde_json::json; + + use crate::Session; + + fn deck() -> (Session, u32) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32; + (s, id) + } + + fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) { + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + let b = &v["box"]; + (b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0)) + } + + fn undos(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + #[test] + fn sets_position_size_and_rotation_in_one_undo() { + let (mut s, id) = deck(); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap(); + assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0)); + assert_eq!(undos(&s), n + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); + } + + #[test] + fn lock_aspect_and_scale_share_one_step() { + let (mut s, id) = deck(); + s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0)); + // An explicit width wins over a scale on the same axis. + s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap(); + assert_eq!(geom(&mut s, id).2, 80.0); + } + + #[test] + fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() { + let (mut s, id) = deck(); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0)); + assert_eq!(undos(&s), n + 1); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap(); + assert_eq!(undos(&s), n, "identical geometry is not an undo step"); + } + + #[test] + fn no_fields_opens_the_dialog_without_editing() { + let (mut s, id) = deck(); + let n = undos(&s); + s.execute("shape.sizePosition", &json!({})).unwrap(); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); + assert_eq!(undos(&s), n); + let req = s.ui_requests.last().unwrap(); + match req { + crate::UiRequest::Dialog { id, params } => { + assert_eq!(id, "sizePosition"); + assert_eq!(params["w"], 200.0); + assert_eq!(params["h"], 100.0); + assert_eq!(params["x"], 40.0); + assert_eq!(params["rot"], 0.0); + assert_eq!(params["scaleW"], 100.0); + assert_eq!(params["lockAspect"], false); + } + other => panic!("expected a dialog, got {other:?}"), + } + } + + #[test] + fn bad_fields_do_not_edit() { + let (mut s, id) = deck(); + let n = undos(&s); + for p in [ + json!({"w": "wide"}), + json!({"h": -5.0}), + json!({"scaleW": 0.0}), + json!({"x": 1e9}), + json!({"rot": "spin"}), + json!({"lockAspect": "yes"}), + ] { + let err = s.execute("shape.sizePosition", &p).unwrap_err(); + assert!(err.to_string().contains("sizePosition"), "{err}"); + assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0)); + assert_eq!(undos(&s), n); + } + } + + #[test] + fn scale_is_per_shape_and_nothing_selected_is_disabled() { + let (mut s, a) = deck(); + let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32; + s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap(); + assert_eq!(geom(&mut s, a).2, 400.0); + assert_eq!(geom(&mut s, a).3, 100.0); + assert_eq!(geom(&mut s, b).2, 80.0); + assert_eq!(geom(&mut s, b).3, 80.0); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…"); + s.execute("edit.select", &json!({"ids": []})).unwrap(); + let err = s.execute("shape.sizePosition", &json!({})).unwrap_err(); + assert!(err.to_string().contains("nothing selected"), "{err}"); + } +} diff --git a/crates/engine/src/cmd/smartart.rs b/crates/engine/src/cmd/smartart.rs index 30e38aa6d..3dbcf40f9 100644 --- a/crates/engine/src/cmd/smartart.rs +++ b/crates/engine/src/cmd/smartart.rs @@ -17,6 +17,21 @@ use serde_json::{Value, json}; use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, current_shapes, has_selection, shape_of, str_param, targets, xfrm_of}; use crate::{EngineError, Result, Session}; +mod diagrams; +mod edit; +mod layout; +mod nodes; +mod paint; + +use edit::{ + add_node, change_colors, change_layout, change_style, demote, from_text, nodes, promote, remove_node, set_nodes, set_picture, to_shapes, to_text, +}; +pub use edit::{insert, selected}; +pub use nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove}; +pub use paint::{BRICK_CAMERA, is_3d, layout}; +#[cfg(test)] +use paint::{MIN_SCALE, accent, layout_items, shade}; + /// Deepest outline level the Text Pane allows. pub const MAX_LEVEL: u8 = 8; /// Most top-level shapes a layout draws. @@ -264,1780 +279,6 @@ pub fn specs() -> Vec { ] } -/// Nodes from text lines: each leading tab is one outline level. -pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { - lines - .into_iter() - .filter(|l| !l.trim().is_empty()) - .map(|l| { - let tabs = l.chars().take_while(|c| *c == '\t').count(); - DiagramNode { - text: l.trim().to_string(), - level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), - picture: None, - assistant: false, - title: String::new(), - } - }) - .collect() -} - -/// Levels made consistent: the first node is top level and no node is more than one level -/// deeper than the node before it. -pub fn normalize(nodes: &mut [DiagramNode]) { - let mut prev: Option = None; - for n in nodes.iter_mut() { - n.level = match prev { - None => 0, - Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), - }; - prev = Some(n.level); - } -} - -/// Index one past the last descendant of node `i`. -fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { - let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); - let mut j = i + 1; - while nodes.get(j).is_some_and(|n| n.level > lv) { - j += 1; - } - j -} - -/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the -/// new node's index. -pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { - let Some(i) = index.filter(|i| *i < nodes.len()) else { - nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() }); - return nodes.len() - 1; - }; - let lv = nodes[i].level; - let (at, level) = match position { - "before" => (i, lv), - "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), - "above" => { - let end = subtree_end(nodes, i); - for n in &mut nodes[i..end] { - n.level = (n.level + 1).min(MAX_LEVEL); - } - (i, lv) - } - "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), - _ => (subtree_end(nodes, i), lv), - }; - nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() }); - at -} - -/// Remove node `i`; its sub-points move up one level. -pub fn remove(nodes: &mut Vec, i: usize) -> bool { - if i >= nodes.len() { - return false; - } - let end = subtree_end(nodes, i); - for n in &mut nodes[i + 1..end] { - n.level = n.level.saturating_sub(1); - } - nodes.remove(i); - normalize(nodes); - true -} - -/// Move node `i` and its sub-points one level up. False when it is already top level. -pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { - if nodes.get(i).is_none_or(|n| n.level == 0) { - return false; - } - let end = subtree_end(nodes, i); - for n in &mut nodes[i..end] { - n.level -= 1; - } - true -} - -/// Move node `i` and its sub-points one level down, under the node before it. False when there is -/// nothing to go under. -pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { - let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; - if cur.level > prev.level || cur.level >= MAX_LEVEL { - return false; - } - let end = subtree_end(nodes, i); - if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { - return false; - } - for n in &mut nodes[i..end] { - n.level += 1; - } - true -} - -/// A generated shape and the nodes whose text it shows (none for arrows and lines). -type Item = (Shape, Vec); - -/// Top-level nodes, each with its sub-points. -fn groups(d: &Diagram) -> Vec> { - let mut out: Vec> = vec![]; - for (i, n) in d.nodes.iter().enumerate() { - match out.last_mut() { - Some(g) if n.level > 0 => g.push(i), - _ => out.push(vec![i]), - } - } - out.truncate(MAX_SHAPES); - out -} - -/// Read text typed into the diagram's shapes back into its nodes. -fn sync_text(d: &mut Diagram, children: &[Shape]) { - if d.imported { - return; - } - let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); - for (shape, idx) in children.iter().zip(map) { - let Some(body) = &shape.text else { continue }; - if idx.is_empty() || body.paragraphs.len() != idx.len() { - continue; - } - for (p, i) in body.paragraphs.iter().zip(idx) { - if let Some(n) = d.nodes.get_mut(i) { - n.text = p.text(); - } - } - } -} - -fn slot(i: usize) -> SchemeSlot { - [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] -} - -/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` -/// keeps one. -fn accent(d: &Diagram, i: usize) -> SchemeSlot { - let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); - if let Some(r) = d.colors.strip_prefix("range") - && let Some(a @ 1..=6) = digit(r, 0) - { - // Colorful Range: alternate between two neighbouring accents. - return slot(a - 1 + i % 2); - } - match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { - Some(n @ 1..=6) => slot(n - 1), - _ => slot(i), - } -} - -/// Gradient Range: each shape a step darker than the one before (the steps cycle). -fn shade(d: &Diagram, i: usize) -> Option { - d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) -} - -/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) -/// lit by the three-point rig. -fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { - format!( - "", - (w * 12700.0).round() as i64, - (h * 12700.0).round() as i64 - ) -} - -/// Brick Scene's camera: the diagram's shapes seen from above and to the side. -pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; - -/// Whether `style` is one of the 3-D SmartArt Styles. -pub fn is_3d(style: &str) -> bool { - matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") -} - -/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet -/// boxes of the bullet lists) are a pale tint of the accent with dark text. -fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { - let a = accent(d, i); - let base = match shade(d, i) { - Some(t) => ColorRef::scheme(a).with(t), - None => ColorRef::scheme(a), - }; - let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); - let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); - sh.style = Some(ShapeStyle::accent(a)); - match d.style.as_str() { - _ if light => { - sh.fill = Some(Fill::solid(tint(80))); - sh.line = Some(Line::solid(tint(60), 1.0)); - } - "outline" => { - sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); - sh.line = Some(Line::solid(base.clone(), 2.25)); - } - "subtle" => { - sh.fill = Some(Fill::solid(base.clone())); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); - sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); - } - "polished" | "inset" | "cartoon" | "powder" | "brick" => { - let (bevel, bw, line, fill) = match d.style.as_str() { - "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), - "inset" => ("relaxedInset", 10.0, None, base.clone()), - "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), - "powder" => ("convex", 14.0, None, tint(40)), - _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), - }; - sh.fill = Some(Fill::solid(fill)); - sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); - sh.effects = Some(Effects { - outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), - bevel: Some(bevel.into()), - raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), - ..Default::default() - }); - } - "intense" => { - sh.fill = Some(Fill::Gradient(Gradient { - stops: vec![ - GradientStop { pos: 0.0, color: tint(30) }, - GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, - ], - shape: GradientShape::Linear { angle: 90.0, scaled: false }, - rotate_with_shape: true, - tile: [0.0; 4], - })); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); - sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); - } - _ => { - sh.fill = Some(Fill::solid(base.clone())); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); - } - } - if let Some(body) = &mut sh.text { - let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; - for p in &mut body.paragraphs { - for r in &mut p.runs { - r.props.fill = Some(Fill::solid(c.clone())); - } - } - } -} - -fn shadow(blur: f64, dist: f64) -> Shadow { - Shadow { - color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), - blur, - dist, - dir: 90.0, - inner: false, - sx: 1.0, - sy: 1.0, - kx: 0.0, - ky: 0.0, - align: String::new(), - rotate_with_shape: false, - } -} - -/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With -/// `title` false every line is a bullet (the bullet boxes of the bullet lists). -fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { - let mut body = TextBody::default(); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); - let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); - for (k, n) in lines.iter().enumerate() { - let mut para = Paragraph::new(&n.text); - let head = title && k == 0; - if head { - para.props.align = Some(Align::Center); - } else { - para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); - para.props.align = Some(Align::Left); - para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); - para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); - para.props.indent = Some(-10.0); - } - let size = if head { 20.0 } else { 14.0 }; - for r in &mut para.runs { - r.props.size = Some(size); - } - para.end_props.size = Some(size); - body.paragraphs.push(para); - } - if body.paragraphs.is_empty() { - body.paragraphs.push(Paragraph::new("")); - } - if !title { - body.body.anchor = Some(deckcraft_model::text::Anchor::Top); - } - Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } -} - -/// A plain shape with no text (arrows between steps). -fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { - let mut x = x; - x.rot = rot; - Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } -} - -fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { - let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); - x.flip_h = (b.0 < a.0) != (b.1 < a.1); - Shape { - name: "SmartArt connector".into(), - xfrm: Some(x), - geom: Geom::preset("line"), - fill: Some(Fill::None), - line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), - ..Default::default() - } -} - -/// The diagram's shapes laid out in `area` (ids not yet assigned). -pub fn layout(d: &Diagram, area: Xfrm) -> Vec { - layout_items(d, area).into_iter().map(|(s, _)| s).collect() -} - -/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is -/// shrunk per shape so its longest word fits on a line. -fn layout_items(d: &Diagram, area: Xfrm) -> Vec { - let mut v = layout_raw(d, area); - for (sh, _) in &mut v { - fit_words(sh); - } - v -} - -/// Rough advance of a character as a fraction of the font size (a sans text face). -const CHAR_W: f64 = 0.6; -/// Smallest font scale shrinking to fit goes to. -const MIN_SCALE: f64 = 0.25; - -/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so -/// no word is wider than its line and would break mid-word. Overflow in height is left to the -/// body's shrink-on-overflow autofit. -fn fit_words(sh: &mut Shape) { - let Some(w) = sh.xfrm.map(|x| x.w) else { return }; - // Round shapes keep their text in the inscribed rectangle (about 71% of the width). - let text_w = match sh.geom.preset_name() { - Some("ellipse" | "gear6" | "gear9") => w * 0.71, - _ => w, - }; - let Some(body) = &mut sh.text else { return }; - let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); - let mut scale: f64 = 1.0; - for p in &body.paragraphs { - let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); - for r in &p.runs { - let size = r.props.size.unwrap_or(18.0); - let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; - let need = word * size * CHAR_W; - if need > 0.0 { - scale = scale.min(line.max(0.0) / need); - } - } - } - let scale = scale.max(MIN_SCALE); - if scale >= 0.999 { - return; - } - let shrink = |v: &mut Option| { - if let Some(x) = v { - *x = (*x * scale * 2.0).floor() / 2.0; - } - }; - for p in &mut body.paragraphs { - for r in &mut p.runs { - shrink(&mut r.props.size); - } - shrink(&mut p.end_props.size); - shrink(&mut p.props.margin_left); - if let Some(i) = &mut p.props.indent { - *i *= scale; - } - } -} - -/// A picture placeholder for node `node`: its picture when it has one, else a pale box. -fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { - let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; - paint(d, &mut sh, i, true); - if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { - sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); - sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); - } - sh -} - -fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { - let gs = groups(d); - let nodes = |g: &[usize]| -> Vec<&DiagramNode> { g.iter().filter_map(|i| d.nodes.get(*i)).collect() }; - let n = gs.len().max(1); - let nf = n as f64; - let mut out: Vec = vec![]; - // A titled shape for top-level group `i`, painted in its accent. - let titled = |out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]| { - let mut sh = text_shape(preset, x, &nodes(g), true); - paint(d, &mut sh, i, false); - out.push((sh, g.to_vec())); - }; - let arrow = |out: &mut Vec, x: Xfrm, rot: f64, i: usize| { - let mut sh = deco("rightArrow", x, rot); - paint(d, &mut sh, i, true); - out.push((sh, vec![])); - }; - match d.kind.as_str() { - "hierarchy" => return hierarchy(d, area, false), - "horizontalHierarchy" => return hierarchy(d, area, true), - "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), - "list" | "hBulletList" => { - // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. - let vertical = d.kind == "list"; - for (i, g) in gs.iter().enumerate() { - let (cell, bar_h) = if vertical { - ( - Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), - (area.h / nf * 0.92).min(area.h * 0.12).max(18.0), - ) - } else { - let w = area.w / nf; - (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) - }; - let has_sub = g.len() > 1; - let bar_h = bar_h.min(cell.h); - titled(&mut out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); - if has_sub { - let pad = cell.w * 0.03; - let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); - let mut sh = text_shape("rect", x, &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - out.push((sh, g[1..].to_vec())); - } - } - } - "blockList" => { - // Basic Block List: equal blocks in rows, as square as the area allows. - let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); - let rows = n.div_ceil(cols); - let (w, h) = (area.w / cols as f64, area.h / rows as f64); - let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let in_row = if r + 1 == rows { n - r * cols } else { cols }; - let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; - titled(&mut out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); - } - } - "process" | "chevron" => { - let chevron = d.kind == "chevron"; - let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; - let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); - let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); - let y = area.y + (area.h - h) / 2.0; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * (w + gap); - titled(&mut out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); - if !chevron && i + 1 < n { - let aw = gap * 0.7; - arrow(&mut out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); - } - } - } - "verticalProcess" => { - let gap = area.h * 0.06; - let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); - let w = area.w * 0.6; - let x = area.x + (area.w - w) / 2.0; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * (h + gap); - titled(&mut out, "roundRect", Xfrm::new(x, y, w, h), i, g); - if i + 1 < n { - let s = gap * 0.8; - // A right arrow turned to point down. - arrow(&mut out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); - } - } - } - "stepUp" => { - // Step Up Process: each step higher than the one before. - let w = area.w / nf; - let h = area.h / (nf + 1.0) * 1.6; - for (i, g) in gs.iter().enumerate() { - let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); - titled(&mut out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); - } - } - "cycle" | "blockCycle" => { - let block = d.kind == "blockCycle"; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; - titled( - &mut out, - if block { "roundRect" } else { "ellipse" }, - Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), - i, - g, - ); - if n > 1 { - // An arrow halfway to the next node, along the circle. - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.35).max(6.0); - let rot = (i as f64 + 0.5) / nf * 360.0; - arrow(&mut out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); - } - } - } - "radial" => { - // Basic Radial: the first node in the middle, the others around it with spokes. - let Some((centre, rest)) = gs.split_first() else { return out }; - let m = rest.len().max(1) as f64; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dc = r * 0.75; - let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); - for (k, _) in rest.iter().enumerate() { - let a = (k as f64 / m * 360.0 - 90.0).to_radians(); - let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); - out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); - } - titled(&mut out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); - for (k, g) in rest.iter().enumerate() { - let a = (k as f64 / m * 360.0 - 90.0).to_radians(); - titled(&mut out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); - } - } - "pyramid" | "invertedPyramid" => { - // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as - // its own polygon so the sides keep the triangle's slope and the text stays upright. - let inverted = d.kind == "invertedPyramid"; - let h = area.h / nf; - let pw = area.h.min(area.w) * 1.15; - let px = area.x + (area.w - pw) / 2.0; - for (i, g) in gs.iter().enumerate() { - let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); - let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; - let (wt, wb) = (pw * t, pw * bt); - let w = wt.max(wb).max(1.0); - let bh = h * 0.96; - let x = px + (pw - w) / 2.0; - let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); - let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); - let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &nodes(g), true); - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], - }; - // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. - // The tip band is a triangle: its text sits at the wide end, inset to the width there. - let tip = wt.min(wb) < 1e-6; - let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; - if let Some(b) = &mut sh.text { - b.body.inset_l = Some(inset); - b.body.inset_r = Some(inset); - if tip { - b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); - } - } - paint(d, &mut sh, i, false); - out.push((sh, g.clone())); - } - } - "matrix" | "titledMatrix" => { - let titled_m = d.kind == "titledMatrix"; - let s = area.h.min(area.w); - let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); - let (quads, title): (&[Vec], Option<&Vec>) = - if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (&gs, None) }; - for (i, g) in quads.iter().take(4).enumerate() { - let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); - titled(&mut out, "roundRect", x, i + usize::from(titled_m), g); - } - if let Some(t) = title { - let c = s * 0.42; - let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &nodes(t), true); - paint(d, &mut sh, 0, true); - out.push((sh, t.clone())); - } - } - "pictureList" => { - // Vertical Picture List: a picture square at the left of each row, the text beside it. - let h = area.h / nf; - let bh = h * 0.88; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * h; - let s = bh.min(area.w * 0.3); - out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); - titled(&mut out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); - } - } - "pictureCaption" | "pictureProcess" => { - // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one - // row, arrows between the steps. - let process = d.kind == "pictureProcess"; - let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; - let rows = n.div_ceil(cols); - let gap = if process { area.w * 0.05 } else { 0.0 }; - let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; - let h = area.h / rows as f64; - let bw = if process { w } else { w * 0.9 }; - let ch = (h * 0.9).min(bw * 1.1); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let in_row = if r + 1 == rows { n - r * cols } else { cols }; - let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; - let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; - let y = area.y + r as f64 * h + (h - ch) / 2.0; - let ph = ch * 0.62; - out.push(( - picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), - vec![], - )); - titled(&mut out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); - if process && i + 1 < n { - let a = (gap * 0.7).min(ch * 0.2); - arrow(&mut out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); - } - } - } - "textCycle" | "continuousCycle" => { - // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on - // a ring. - let text_only = d.kind == "textCycle"; - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); - if !text_only { - let ring = r * 2.0 + dd * 0.3; - let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); - sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - } - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - let (w, h) = (dd * 1.35, dd * 0.7); - let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &nodes(g), true); - paint(d, &mut sh, i, false); - if text_only { - sh.fill = Some(Fill::None); - sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - sh.effects = None; - let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); - if let Some(b) = &mut sh.text { - for p in &mut b.paragraphs { - for r in &mut p.runs { - r.props.fill = Some(Fill::solid(c.clone())); - } - } - } - if n > 1 { - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.3).max(6.0); - arrow( - &mut out, - Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), - (i as f64 + 0.5) / nf * 360.0, - i, - ); - } - } - out.push((sh, g.clone())); - } - } - "segmentedCycle" | "pie" => { - // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. - // The text sits in a clear box over each segment's middle. - let ring = d.kind == "segmentedCycle"; - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let gap = if ring { 2.0 } else { 0.0 }; - for (i, g) in gs.iter().enumerate() { - // Angles clockwise from 3 o'clock, starting at the top. - let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; - let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; - let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); - let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); - // blockArc runs from its first angle to its second clockwise; so does pie. - sh.geom = Geom::Preset { - name: if ring { "blockArc" } else { "pie" }.into(), - adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, - }; - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - let m = ((a0 + a1) / 2.0).to_radians(); - let rr = if ring { s * 0.4375 } else { s * 0.3 }; - let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); - let th = s * 0.16; - let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &nodes(g), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - "multidirectionalCycle" => { - // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. - let r = area.h.min(area.w) * 0.36; - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); - titled(&mut out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); - if n > 1 { - let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); - let s = (dd * 0.5).max(8.0); - let mut sh = deco( - "leftRightArrow", - Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), - (i as f64 + 0.5) / nf * 360.0, - ); - paint(d, &mut sh, i, true); - out.push((sh, vec![])); - } - } - } - "cycleMatrix" => { - // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each - // quarter's sub-points in a box at its corner. - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let c = s * 0.56; - for (i, g) in gs.iter().take(4).enumerate() { - let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); - let b = s * 0.4; - let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); - if g.len() > 1 { - let mut sh = text_shape("roundRect", bx, &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - if let Some(body) = &mut sh.text { - // Text in the box's outer part, clear of the circle. - body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); - body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); - body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); - } - out.push((sh, g[1..].to_vec())); - } - } - for (i, g) in gs.iter().take(4).enumerate() { - let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); - let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; - let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); - sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - let q = c * 0.3; - let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &nodes(&g[..1]), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g[..1].to_vec())); - } - } - "segmentedPyramid" => { - // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, - // every other one upside down. - let rows = (nf.sqrt().ceil() as usize).max(1); - let s = area.h.min(area.w / 1.15); - let pw = s * 1.15; - let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); - let (tw, th) = (pw / rows as f64, s / rows as f64); - let mut k = 0; - 'rows: for r in 0..rows { - let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; - for c in 0..(2 * r + 1) { - let Some(g) = gs.get(k) else { break 'rows }; - let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); - let mut sh = text_shape("rect", x, &nodes(g), true); - // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. - let path = if c % 2 == 1 { - format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) - } else { - format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) - }; - sh.geom = Geom::Custom { - paths: vec![deckcraft_model::CustomPath { - w: tw, - h: th, - d: path, - fill: deckcraft_geom::preset::FillMode::Norm, - stroke: true, - }], - }; - if let Some(b) = &mut sh.text { - // The text in the triangle's wide half. - b.body.inset_l = Some(tw * 0.22); - b.body.inset_r = Some(tw * 0.22); - if c % 2 == 1 { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - b.body.inset_b = Some(th * 0.35); - } else { - b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); - b.body.inset_t = Some(th * 0.35); - } - } - paint(d, &mut sh, k, false); - out.push((sh, g.clone())); - k += 1; - } - } - } - "verticalBoxList" => { - // Vertical Box List: a pale box per node with its sub-points, its title on a tab - // overlapping the box's top left. - let h = area.h / nf; - for (i, g) in gs.iter().enumerate() { - let y = area.y + i as f64 * h; - let tab_h = (h * 0.3).min(40.0); - let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &nodes(&g[1..]), false); - paint(d, &mut bx, i, true); - if let Some(b) = &mut bx.text { - b.body.inset_t = Some(tab_h * 0.6); - } - out.push((bx, g[1..].to_vec())); - titled(&mut out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); - } - } - "trapezoidList" | "tableList" => { - // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the - // first node as a header bar over a column per other node. - let table = d.kind == "tableList"; - let (head, cols): (Option<&Vec>, &[Vec]) = - if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, &gs) }; - let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; - if let Some(h) = head { - titled(&mut out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); - } - let m = cols.len().max(1) as f64; - let w = area.w / m; - for (k, g) in cols.iter().enumerate() { - let i = k + usize::from(table); - let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); - let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &nodes(g), true); - if let Some(b) = &mut sh.text { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - if !table { - b.body.inset_l = Some(x.w * 0.2); - b.body.inset_r = Some(x.w * 0.2); - b.body.inset_t = Some(x.h * 0.08); - } - } - paint(d, &mut sh, i, table); - out.push((sh, g.clone())); - } - } - "accentProcess" => { - // Accent Process: a title box per step with its sub-points in a pale box below and to the - // right, arrows between the titles. - let gap = area.w * 0.05; - let w = (area.w - gap * (nf - 1.0)) / nf; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * (w + gap); - let th = area.h * 0.22; - if g.len() > 1 { - let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &nodes(&g[1..]), false); - paint(d, &mut sh, i, true); - if let Some(b) = &mut sh.text { - b.body.inset_t = Some(th * 0.45); - } - out.push((sh, g[1..].to_vec())); - } - titled(&mut out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); - if i + 1 < n { - let a = gap * 0.8; - arrow(&mut out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); - } - } - } - "continuousBlockProcess" => { - // Continuous Block Process: one big pale arrow behind a row of boxes. - let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); - paint(d, &mut big, 0, true); - out.push((big, vec![])); - let w = area.w * 0.85 / nf; - let h = area.h * 0.32; - for (i, g) in gs.iter().enumerate() { - let x = area.x + area.w * 0.02 + i as f64 * w; - titled(&mut out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); - } - } - "bendingProcess" => { - // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. - let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); - let rows = n.div_ceil(cols); - let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); - let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); - for (i, g) in gs.iter().enumerate() { - let (c, r) = (i % cols, i / cols); - let x = area.x + c as f64 * cw; - let y = area.y + r as f64 * rh + (rh - bh) / 2.0; - titled(&mut out, "rect", Xfrm::new(x, y, bw, bh), i, g); - if i + 1 < n { - let a = (cw - bw) * 0.7; - if c + 1 < cols { - arrow(&mut out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); - } else { - // The bend: down from the row's last box towards the next row's first. - let a = (rh - bh).min(a * 1.5).max(6.0); - arrow(&mut out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); - } - } - } - } - "linearVenn" | "stackedVenn" => { - // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, - // the text near each one's top. - let linear = d.kind == "linearVenn"; - if linear { - let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); - let step = dc * 0.75; - let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; - for (i, g) in gs.iter().enumerate() { - let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &nodes(g), true); - paint(d, &mut sh, i, false); - if let Some(Fill::Solid { color: c }) = &mut sh.fill { - *c = c.clone().with(ColorTransform::Alpha(55_000)); - } - out.push((sh, g.clone())); - } - } else { - let s = area.h.min(area.w); - let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); - for (i, _) in gs.iter().enumerate() { - let dc = s * (1.0 - i as f64 / nf); - let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - // Each circle's text in the band between its top and the next circle's. - let band = s / nf; - for (i, g) in gs.iter().enumerate() { - let dc = s * (1.0 - i as f64 / nf); - let top = bottom - dc; - let h = if i + 1 < n { band } else { dc }; - let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; - let mut t = - text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &nodes(g), true); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - } - "nestedTarget" => { - // Nested Target: rounded boxes nested inside each other, the text on each one's top band. - let band_x = area.w / 2.0 / nf * 0.6; - let band_y = area.h / nf * 0.9 / 2.0; - for (i, _) in gs.iter().enumerate() { - let (dx, dy) = (band_x * i as f64, band_y * i as f64); - let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - for (i, g) in gs.iter().enumerate() { - let (dx, dy) = (band_x * i as f64, band_y * i as f64); - let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; - let mut t = text_shape( - "rect", - Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), - &nodes(g), - true, - ); - paint(d, &mut t, i, false); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - t.effects = None; - out.push((t, g.clone())); - } - } - "gridMatrix" => { - // Grid Matrix: four quadrants on crossed axes. - let s = area.h.min(area.w); - let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); - let t = s * 0.06; - for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] - { - let mut sh = deco(preset, x, 0.0); - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - } - let q = s / 2.0 - t * 1.2; - for (i, g) in gs.iter().take(4).enumerate() { - let (c, r) = ((i % 2) as f64, (i / 2) as f64); - let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); - titled(&mut out, "rect", x, i, g); - } - } - "gear" => { - // Gear: up to three interlocking gears, the first the biggest. - let s = area.h.min(area.w * 0.62); - let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); - let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; - for (i, g) in gs.iter().take(3).enumerate() { - let (fx, fy, fs, preset) = spots[i]; - let z = s * fs; - titled(&mut out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); - } - } - "funnel" => { - // Funnel: the ideas poured in at the top (up to three), the last one coming out below. - let s = area.h.min(area.w); - let (cx, y0) = (area.x + area.w / 2.0, area.y); - let fw = s * 0.8; - let fh = s * 0.62; - let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); - paint(d, &mut f, 0, true); - out.push((f, vec![])); - let (inside, last) = match gs.split_last() { - Some((l, r)) if !r.is_empty() => (r, Some(l)), - _ => (&gs[..], None), - }; - let k = inside.len().min(3); - let dc = fw * 0.27; - let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; - for (i, g) in inside.iter().take(3).enumerate() { - let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; - titled(&mut out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); - } - if let Some(g) = last { - let a = s * 0.12; - let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); - paint(d, &mut ar, k, true); - out.push((ar, vec![])); - titled(&mut out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); - } - } - "pictureHList" => { - // Horizontal Picture List: a column per node, a big picture over its text. - let w = area.w / nf; - for (i, g) in gs.iter().enumerate() { - let x = area.x + i as f64 * w + w * 0.04; - let bw = w * 0.92; - let ph = area.h * 0.55; - out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); - let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &nodes(g), true); - if let Some(b) = &mut sh.text { - b.body.anchor = Some(deckcraft_model::text::Anchor::Top); - } - paint(d, &mut sh, i, false); - out.push((sh, g.clone())); - } - } - "timeline" => { - // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and - // below in turn. - let mid = area.y + area.h / 2.0; - let bar = (area.h * 0.08).max(6.0); - let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); - paint(d, &mut sh, 0, true); - out.push((sh, vec![])); - let w = area.w * 0.92 / nf; - for (i, g) in gs.iter().enumerate() { - let cx = area.x + w * (i as f64 + 0.5); - let dot = bar * 1.4; - let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); - paint(d, &mut dsh, i, false); - out.push((dsh, vec![])); - let th = area.h / 2.0 - bar * 1.6; - let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; - let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &nodes(g), true); - if let Some(b) = &mut t.text { - b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); - } - paint(d, &mut t, i, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - out.push((t, g.clone())); - } - } - "venn" => { - // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). - let s = area.h.min(area.w); - let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); - let dc = if n == 1 { s } else { s * 0.62 }; - let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; - for (i, g) in gs.iter().enumerate() { - let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); - let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &nodes(g), true); - paint(d, &mut sh, i, false); - if let Some(Fill::Solid { color: c }) = &mut sh.fill { - *c = c.clone().with(ColorTransform::Alpha(50_000)); - } - if let Some(b) = &mut sh.text { - // Text towards the circle's outer side, clear of the overlap. - let p = dc * 0.36; - let (dx, dy) = (a.cos() * p, a.sin() * p); - b.body.inset_l = Some((7.2 + dx).max(2.0)); - b.body.inset_r = Some((7.2 - dx).max(2.0)); - b.body.inset_t = Some((3.6 + dy).max(2.0)); - b.body.inset_b = Some((3.6 - dy).max(2.0)); - } - out.push((sh, g.clone())); - } - } - "target" => { - // Basic Target: nested rings, the outermost first, each ring's text in a box beside it - // with a line from the ring. - let s = area.h.min(area.w * 0.5); - let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); - let band = s / 2.0 / nf; - for (i, _) in gs.iter().enumerate() { - let dc = s - 2.0 * band * i as f64; - let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); - paint(d, &mut sh, i, false); - out.push((sh, vec![])); - } - let tx = area.x + s + area.w * 0.06; - let rh = area.h / nf; - for (i, g) in gs.iter().enumerate() { - let ty = area.y + rh * (i as f64 + 0.5); - // The point on ring i's band level with the text, else its top. - let ry = cy - s / 2.0 + band * (i as f64 + 0.5); - out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); - let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &nodes(g), true); - paint(d, &mut t, i, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - if let Some(b) = &mut t.text { - for p in &mut b.paragraphs { - p.props.align = Some(Align::Left); - } - } - out.push((t, g.clone())); - } - } - _ => {} - } - out -} - -/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on -/// a row of their own between the parent and its other children. `half_circle` draws each node as -/// its text in an open half ring (Half Circle Organization Chart). -fn org_chart(d: &Diagram, area: Xfrm) -> Vec { - let half_circle = d.kind == "halfCircleOrgChart"; - let name_title = d.kind == "nameTitleOrgChart"; - let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; - if nodes.is_empty() { - return vec![]; - } - let mut parent: Vec> = vec![None; nodes.len()]; - let mut stack: Vec = vec![]; - for (i, n) in nodes.iter().enumerate() { - while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { - stack.pop(); - } - parent[i] = stack.last().copied(); - stack.push(i); - } - let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); - let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; - let mut leaves = vec![0usize; nodes.len()]; - for i in (0..nodes.len()).rev() { - let k = kids(Some(i)); - leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; - } - // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. - let mut slot = vec![0usize; nodes.len()]; - for i in 0..nodes.len() { - slot[i] = match parent[i] { - None => 0, - Some(p) if asst(i) => slot[p] + 1, - Some(p) => slot[p] + 2, - }; - } - let mut used: Vec = slot.clone(); - used.sort_unstable(); - used.dedup(); - let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); - let rows = used.len().max(1); - let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); - let cell_w = area.w / total as f64; - let row_h = area.h / rows as f64; - let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62); - let mut left = vec![0usize; nodes.len()]; - let mut next_root = 0; - for i in 0..nodes.len() { - if asst(i) { - continue; - } - let mut start = match parent[i] { - None => { - let s = next_root; - next_root += leaves[i]; - s - } - Some(p) => left[p], - }; - if let Some(p) = parent[i] { - for s in kids(Some(p)) { - if s == i { - break; - } - start += leaves[s]; - } - } - left[i] = start; - } - let mut side = vec![0usize; nodes.len()]; - let boxes: Vec = (0..nodes.len()) - .map(|i| { - let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; - match parent[i] { - Some(p) if asst(i) => { - // Assistants alternate right and left of the parent's trunk. - let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); - side[i] = k; - let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; - let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; - let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; - Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) - } - _ => { - let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; - Xfrm::new(cx - bw / 2.0, y, bw, bh) - } - } - }) - .collect(); - let mut out: Vec = vec![]; - for i in 0..nodes.len() { - let Some(p) = parent[i] else { continue }; - let (a, b) = (boxes[p], boxes[i]); - let pc = a.x + a.w / 2.0; - if asst(i) { - let my = b.y + b.h / 2.0; - out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); - let ex = if b.x > pc { b.x } else { b.x + b.w }; - out.push((line_shape((pc, my), (ex, my)), vec![])); - } else { - let mid = b.y - (row_h - bh) / 2.0; - let cc = b.x + b.w / 2.0; - out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); - out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); - out.push((line_shape((cc, mid), (cc, b.y)), vec![])); - } - } - let _ = side; - for (i, n) in nodes.iter().enumerate() { - let depth = slot[i] / 2; - if half_circle { - // Two half rings round the text: the upper left one in the accent, the lower right one - // in its tint. - let x = boxes[i]; - for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() { - let mut arc = deco("blockArc", x, 0.0); - arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] }; - paint(d, &mut arc, depth, false); - if k == 1 { - let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000)); - arc.fill = Some(Fill::solid(c)); - } - arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - arc.effects = None; - out.push((arc, vec![])); - } - let mut t = text_shape("rect", x, &[n], true); - paint(d, &mut t, depth, true); - t.fill = Some(Fill::None); - t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); - out.push((t, vec![i])); - } else { - let mut x = boxes[i]; - if name_title { - // The name in the upper part; the title box overlaps the lower edge. - x.h *= 0.85; - } - let mut sh = text_shape("rect", x, &[n], true); - paint(d, &mut sh, depth, false); - out.push((sh, vec![i])); - if name_title { - let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42); - let title = DiagramNode { text: n.title.clone(), ..Default::default() }; - let mut t = text_shape("rect", tb, &[&title], true); - paint(d, &mut t, depth, true); - t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); - if let Some(b) = &mut t.text { - for p in &mut b.paragraphs { - for r in &mut p.runs { - r.props.size = Some(11.0); - } - p.end_props.size = Some(11.0); - } - } - out.push((t, vec![])); - } - } - } - out -} - -/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. -fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { - let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; - if nodes.is_empty() { - return vec![]; - } - let mut parent: Vec> = vec![None; nodes.len()]; - let mut stack: Vec = vec![]; - for (i, n) in nodes.iter().enumerate() { - while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { - stack.pop(); - } - parent[i] = stack.last().copied(); - stack.push(i); - } - let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; - let mut leaves = vec![0usize; nodes.len()]; - for i in (0..nodes.len()).rev() { - let k = kids(Some(i)); - leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; - } - let depth: Vec = (0..nodes.len()) - .map(|i| { - let (mut d, mut p) = (0, parent[i]); - while let Some(q) = p { - d += 1; - p = parent[q]; - } - d - }) - .collect(); - let rows = depth.iter().max().copied().unwrap_or(0) + 1; - let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); - // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. - let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; - let cell_w = span / total as f64; - let row_h = across / rows as f64; - let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; - // Left edge (in leaves) of each node's span. - let mut left = vec![0usize; nodes.len()]; - let mut next_root = 0; - for i in 0..nodes.len() { - let mut start = match parent[i] { - None => { - let s = next_root; - next_root += leaves[i]; - s - } - Some(p) => left[p], - }; - if let Some(p) = parent[i] { - for s in kids(Some(p)) { - if s == i { - break; - } - start += leaves[s]; - } - } - left[i] = start; - } - let boxes: Vec = (0..nodes.len()) - .map(|i| { - let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; - let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; - if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } - }) - .collect(); - let mut out: Vec = nodes - .iter() - .enumerate() - .map(|(i, n)| { - let mut sh = text_shape("roundRect", boxes[i], &[n], true); - paint(d, &mut sh, depth[i], false); - (sh, vec![i]) - }) - .collect(); - for i in 0..nodes.len() { - if let Some(p) = parent[i] { - let (a, b) = (boxes[p], boxes[i]); - let (p, q) = if horizontal { - ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) - } else { - ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) - }; - out.push((line_shape(p, q), vec![])); - } - } - out -} - -/// The SmartArt group that `id` is, or is inside. -fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { - for sh in shapes { - if sh.id == id && sh.diagram.is_some() { - return Some(sh); - } - if let ShapeKind::Group { children, .. } = &sh.kind { - if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { - return Some(sh); - } - if let Some(g) = diagram_group(children, id) { - return Some(g); - } - } - } - None -} - -/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. -pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { - let shapes = current_shapes(&st.doc, &st.selection)?; - let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; - let mut d = g.diagram.clone()?; - if let ShapeKind::Group { children, .. } = &g.kind { - sync_text(&mut d, children); - } - Some((g.id, d)) -} - -fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { - let st = s.doc()?; - let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; - let ids = targets(s, p)?; - ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) -} - -/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. -fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result> { - let mut v = layout(d, area); - for sh in &mut v { - sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); - } - Ok(v) -} - -/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. -fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { - let gid = target_group(s, p, cmd)?; - s.edit(|doc, sel| { - let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; - let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; - sync_text(&mut d, &children); - let out = f(&mut d)?; - normalize(&mut d.nodes); - d.imported = false; - let kids = regenerate(cmd, doc, &d, child)?; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; - let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; - sh.kind = ShapeKind::Group { children: kids, child }; - sh.diagram = Some(d); - sel.shapes = vec![gid]; - sel.text = None; - Ok(out) - }) -} - -fn index(p: &Value, cmd: &str) -> Result { - p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) -} - -/// Insert a new SmartArt graphic (`insert.smartArt`). -pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { - let kind = if is_kind(kind) { kind } else { "process" }; - if nodes.is_empty() { - nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect(); - } - normalize(&mut nodes); - let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; - let size = s.doc()?.doc.slide_size; - let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); - let id = s.edit(|doc, sel| { - let children = regenerate("insert.smartArt", doc, &d, area)?; - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?); - let g = Shape { - id, - name: format!("SmartArt ({kind})"), - xfrm: Some(area), - kind: ShapeKind::Group { children, child: area }, - diagram: Some(d), - ..Default::default() - }; - crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); - sel.shapes = vec![id]; - sel.text = None; - Ok(id) - })?; - Ok(json!({"id": id.0})) -} - -fn nodes(s: &mut Session, p: &Value) -> Result { - let gid = target_group(s, p, "smartArt.nodes")?; - let g = shape_of(s, gid)?; - let mut d = g.diagram.unwrap_or_default(); - if let ShapeKind::Group { children, .. } = &g.kind { - sync_text(&mut d, children); - } - Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) -} - -fn set_nodes(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.setNodes"; - let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { - (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), - (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), - _ => None, - }; - let one = p.get("index").map(|_| index(p, CMD)).transpose()?; - let text = str_param(p, "text").map(String::from); - let title = str_param(p, "title").map(String::from); - edit_diagram(s, p, CMD, |d| { - if let Some(n) = given { - d.nodes = n; - } - if let (Some(i), Some(t)) = (one, text) { - d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; - } - if let (Some(i), Some(t)) = (one, title) { - d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t; - } - Ok(Value::Null) - }) -} - -fn add_node(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); - let pos = str_param(p, "position").unwrap_or("after").to_string(); - let text = str_param(p, "text").unwrap_or("").to_string(); - edit_diagram(s, p, "smartArt.addNode", |d| { - let at = add(&mut d.nodes, i, &pos, &text); - Ok(json!({"index": at})) - }) -} - -fn remove_node(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.removeNode"; - let i = index(p, CMD)?; - edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) -} - -fn promote(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.promote"; - let i = index(p, CMD)?; - edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) -} - -fn demote(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.demote"; - let i = index(p, CMD)?; - edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) -} - -/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in -/// the shape's place. -fn from_text(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.fromText"; - let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string(); - let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?; - let (area, nodes) = { - let st = s.doc()?; - let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; - let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?; - let body = - sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?; - let mut nodes: Vec = body - .paragraphs - .iter() - .filter(|p| !p.text().trim().is_empty()) - .map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() }) - .collect(); - normalize(&mut nodes); - // A small text box grows to a size a diagram can use, keeping its top left on the slide. - let mut a = xfrm_of(&st.doc, &st.selection, sh); - let size = st.doc.slide_size; - a.w = a.w.max(size.width * 0.5).min(size.width); - a.h = a.h.max(size.height * 0.35).min(size.height); - a.x = a.x.min(size.width - a.w).max(0.0); - a.y = a.y.min(size.height - a.h).max(0.0); - a.rot = 0.0; - (a, nodes) - }; - let d = Diagram { kind: kind.clone(), nodes, ..Default::default() }; - let gid = s.edit(|doc, sel| { - let children = regenerate(CMD, doc, &d, area)?; - let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); - let g = Shape { - id: gid, - name: format!("SmartArt ({kind})"), - xfrm: Some(area), - kind: ShapeKind::Group { children, child: area }, - diagram: Some(d), - ..Default::default() - }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?; - // In the text shape's place in the z-order. - shapes[at] = g; - sel.shapes = vec![gid]; - sel.text = None; - Ok(gid) - })?; - Ok(json!({"id": gid.0})) -} - -/// Convert to Text: a text box with the outline, one bulleted paragraph per node. -fn to_text(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.toText"; - let gid = target_group(s, p, CMD)?; - let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; - // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). - let (title_pt, bullet_pt) = { - let st = s.doc()?; - let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; - let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; - text_sizes(g.children()) - }; - let new = s.edit(|doc, sel| { - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; - let area = shapes[at].xfrm.unwrap_or_default(); - let mut body = TextBody::default(); - for n in &d.nodes { - let mut para = Paragraph::new(&n.text); - para.level = n.level.min(MAX_LEVEL); - let size = if n.level == 0 { title_pt } else { bullet_pt }; - for r in &mut para.runs { - r.props.size = Some(size); - } - para.end_props.size = Some(size); - para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); - para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); - para.props.indent = Some(-14.0); - body.paragraphs.push(para); - } - if body.paragraphs.is_empty() { - body.paragraphs.push(Paragraph::new("")); - } - body.body.wrap = Some(true); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); - let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); - let tb = - Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }; - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - shapes[at] = tb; - sel.shapes = vec![id]; - sel.text = None; - Ok(id) - })?; - Ok(json!({"id": new.0})) -} - -/// Convert to Shapes: keep the drawing as an ordinary group. -fn to_shapes(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.toShapes"; - let gid = target_group(s, p, CMD)?; - s.edit(|doc, sel| { - let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; - let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?; - g.diagram = None; - g.name = "Group".into(); - sel.shapes = vec![gid]; - Ok(()) - })?; - Ok(Value::Null) -} - -/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). -fn text_sizes(children: &[Shape]) -> (f64, f64) { - let mut titles: Vec = vec![]; - let mut bullets: Vec = vec![]; - for c in children { - let Some(b) = &c.text else { continue }; - for p in &b.paragraphs { - let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; - if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } - } - } - let mode = |v: &mut Vec, d: f64| { - v.sort_by(|a, b| a.total_cmp(b)); - let mut best = (d, 0usize); - let mut k = 0; - while k < v.len() { - let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); - if j > best.1 { - best = (v[k], j); - } - k += j; - } - best.0 - }; - (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) -} - -/// The graphic `gid` with its outline (typed text synced). -fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { - let st = s.doc().ok()?; - let shapes = current_shapes(&st.doc, &st.selection)?; - let g = diagram_group(shapes, gid)?; - let mut d = g.diagram.clone()?; - if let ShapeKind::Group { children, .. } = &g.kind { - sync_text(&mut d, children); - } - Some((g.id, d)) -} - -fn set_picture(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.picture"; - let i = index(p, CMD)?; - let media = if bool_or(p, "reset", false) { - None - } else { - let (name, bytes) = super::insert::media_bytes(p, CMD)?; - let ct = super::insert::content_type(&name, &bytes); - target_group(s, p, CMD)?; - Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?) - }; - edit_diagram(s, p, CMD, |d| { - // A sub-point's picture is its top-level node's: that's the node the placeholder shows. - let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); - let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; - n.picture = media; - Ok(Value::Null) - }) -} - -fn change_style(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.style"; - let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); - edit_diagram(s, p, CMD, |d| { - if d.style != v { - d.style_xml = None; - } - d.style = v; - Ok(Value::Null) - }) -} - -fn change_colors(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.colors"; - let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string(); - edit_diagram(s, p, CMD, |d| { - if d.colors != v { - d.colors_xml = None; - } - d.colors = v; - Ok(Value::Null) - }) -} - -fn change_layout(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "smartArt.layout"; - let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); - edit_diagram(s, p, CMD, |d| { - if d.kind != kind { - // PowerPoint's own layout no longer applies. - d.layout_id.clear(); - d.layout_xml = None; - } - d.kind = kind; - Ok(Value::Null) - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/smartart/diagrams.rs b/crates/engine/src/cmd/smartart/diagrams.rs new file mode 100644 index 000000000..ef4a70096 --- /dev/null +++ b/crates/engine/src/cmd/smartart/diagrams.rs @@ -0,0 +1,254 @@ +use super::nodes::Item; +use super::paint::{accent, deco, line_shape, paint, text_shape}; +use super::{ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, MAX_SHAPES, SchemeSlot, Xfrm}; + +/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on +/// a row of their own between the parent and its other children. `half_circle` draws each node as +/// its text in an open half ring (Half Circle Organization Chart). +pub(super) fn org_chart(d: &Diagram, area: Xfrm) -> Vec { + let half_circle = d.kind == "halfCircleOrgChart"; + let name_title = d.kind == "nameTitleOrgChart"; + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let asst = |i: usize| nodes[i].assistant && parent[i].is_some(); + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i && !asst(k)).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + // Row slots: a node's level is 2 per depth; assistants take the odd slot under their parent. + let mut slot = vec![0usize; nodes.len()]; + for i in 0..nodes.len() { + slot[i] = match parent[i] { + None => 0, + Some(p) if asst(i) => slot[p] + 1, + Some(p) => slot[p] + 2, + }; + } + let mut used: Vec = slot.clone(); + used.sort_unstable(); + used.dedup(); + let row = |i: usize| used.iter().position(|&s| s == slot[i]).unwrap_or(0); + let rows = used.len().max(1); + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + let cell_w = area.w / total as f64; + let row_h = area.h / rows as f64; + let (bw, bh) = ((cell_w * 0.85).min(area.w * 0.3), row_h * 0.62); + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + if asst(i) { + continue; + } + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let mut side = vec![0usize; nodes.len()]; + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let y = area.y + row(i) as f64 * row_h + (row_h - bh) / 2.0; + match parent[i] { + Some(p) if asst(i) => { + // Assistants alternate right and left of the parent's trunk. + let k = (0..i).filter(|&j| parent[j] == Some(p) && asst(j)).count(); + side[i] = k; + let pc = area.x + (left[p] as f64 + leaves[p] as f64 / 2.0) * cell_w; + let off = bw * 0.15 + (k / 2) as f64 * bw * 1.1; + let x = if k % 2 == 0 { pc + off } else { pc - off - bw }; + Xfrm::new(x.clamp(area.x, area.x + area.w - bw), y, bw, bh) + } + _ => { + let cx = area.x + (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + Xfrm::new(cx - bw / 2.0, y, bw, bh) + } + } + }) + .collect(); + let mut out: Vec = vec![]; + for i in 0..nodes.len() { + let Some(p) = parent[i] else { continue }; + let (a, b) = (boxes[p], boxes[i]); + let pc = a.x + a.w / 2.0; + if asst(i) { + let my = b.y + b.h / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, my)), vec![])); + let ex = if b.x > pc { b.x } else { b.x + b.w }; + out.push((line_shape((pc, my), (ex, my)), vec![])); + } else { + let mid = b.y - (row_h - bh) / 2.0; + let cc = b.x + b.w / 2.0; + out.push((line_shape((pc, a.y + a.h), (pc, mid)), vec![])); + out.push((line_shape((pc.min(cc), mid), (pc.max(cc), mid)), vec![])); + out.push((line_shape((cc, mid), (cc, b.y)), vec![])); + } + } + let _ = side; + for (i, n) in nodes.iter().enumerate() { + let depth = slot[i] / 2; + if half_circle { + // Two half rings round the text: the upper left one in the accent, the lower right one + // in its tint. + let x = boxes[i]; + for (k, (a0, a1)) in [(140.0, 310.0), (320.0, 130.0)].into_iter().enumerate() { + let mut arc = deco("blockArc", x, 0.0); + arc.geom = Geom::Preset { name: "blockArc".into(), adj: vec![a0 * 60_000.0, a1 * 60_000.0, 4_000.0] }; + paint(d, &mut arc, depth, false); + if k == 1 { + let c = ColorRef::scheme(accent(d, depth)).with(ColorTransform::LumMod(60_000)).with(ColorTransform::LumOff(40_000)); + arc.fill = Some(Fill::solid(c)); + } + arc.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + arc.effects = None; + out.push((arc, vec![])); + } + let mut t = text_shape("rect", x, &[n], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, vec![i])); + } else { + let mut x = boxes[i]; + if name_title { + // The name in the upper part; the title box overlaps the lower edge. + x.h *= 0.85; + } + let mut sh = text_shape("rect", x, &[n], true); + paint(d, &mut sh, depth, false); + out.push((sh, vec![i])); + if name_title { + let tb = Xfrm::new(x.x + x.w * 0.12, x.y + x.h * 0.72, x.w * 0.8, x.h * 0.42); + let title = DiagramNode { text: n.title.clone(), ..Default::default() }; + let mut t = text_shape("rect", tb, &[&title], true); + paint(d, &mut t, depth, true); + t.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.size = Some(11.0); + } + p.end_props.size = Some(11.0); + } + } + out.push((t, vec![])); + } + } + } + out +} + +/// An organisation chart: one box per node, rows by level, each subtree as wide as its leaves. +pub(super) fn hierarchy(d: &Diagram, area: Xfrm, horizontal: bool) -> Vec { + let nodes = &d.nodes[..d.nodes.len().min(MAX_SHAPES)]; + if nodes.is_empty() { + return vec![]; + } + let mut parent: Vec> = vec![None; nodes.len()]; + let mut stack: Vec = vec![]; + for (i, n) in nodes.iter().enumerate() { + while stack.last().is_some_and(|&p| nodes[p].level >= n.level) { + stack.pop(); + } + parent[i] = stack.last().copied(); + stack.push(i); + } + let kids = |i: Option| -> Vec { (0..nodes.len()).filter(|&k| parent[k] == i).collect() }; + let mut leaves = vec![0usize; nodes.len()]; + for i in (0..nodes.len()).rev() { + let k = kids(Some(i)); + leaves[i] = if k.is_empty() { 1 } else { k.iter().map(|&c| leaves[c]).sum() }; + } + let depth: Vec = (0..nodes.len()) + .map(|i| { + let (mut d, mut p) = (0, parent[i]); + while let Some(q) = p { + d += 1; + p = parent[q]; + } + d + }) + .collect(); + let rows = depth.iter().max().copied().unwrap_or(0) + 1; + let total: usize = kids(None).iter().map(|&r| leaves[r]).sum::().max(1); + // Horizontal Hierarchy: the same tree turned a quarter, levels from left to right. + let (span, across) = if horizontal { (area.h, area.w) } else { (area.w, area.h) }; + let cell_w = span / total as f64; + let row_h = across / rows as f64; + let (bw, bh) = if horizontal { (cell_w * 0.8, row_h * 0.7) } else { (cell_w * 0.85, row_h * 0.6) }; + // Left edge (in leaves) of each node's span. + let mut left = vec![0usize; nodes.len()]; + let mut next_root = 0; + for i in 0..nodes.len() { + let mut start = match parent[i] { + None => { + let s = next_root; + next_root += leaves[i]; + s + } + Some(p) => left[p], + }; + if let Some(p) = parent[i] { + for s in kids(Some(p)) { + if s == i { + break; + } + start += leaves[s]; + } + } + left[i] = start; + } + let boxes: Vec = (0..nodes.len()) + .map(|i| { + let c = (left[i] as f64 + leaves[i] as f64 / 2.0) * cell_w; + let r = depth[i] as f64 * row_h + (row_h - bh) / 2.0; + if horizontal { Xfrm::new(area.x + r, area.y + c - bw / 2.0, bh, bw) } else { Xfrm::new(area.x + c - bw / 2.0, area.y + r, bw, bh) } + }) + .collect(); + let mut out: Vec = nodes + .iter() + .enumerate() + .map(|(i, n)| { + let mut sh = text_shape("roundRect", boxes[i], &[n], true); + paint(d, &mut sh, depth[i], false); + (sh, vec![i]) + }) + .collect(); + for i in 0..nodes.len() { + if let Some(p) = parent[i] { + let (a, b) = (boxes[p], boxes[i]); + let (p, q) = if horizontal { + ((a.x + a.w, a.y + a.h / 2.0), (b.x, b.y + b.h / 2.0)) + } else { + ((a.x + a.w / 2.0, a.y + a.h), (b.x + b.w / 2.0, b.y)) + }; + out.push((line_shape(p, q), vec![])); + } + } + out +} diff --git a/crates/engine/src/cmd/smartart/edit.rs b/crates/engine/src/cmd/smartart/edit.rs new file mode 100644 index 000000000..77662f71a --- /dev/null +++ b/crates/engine/src/cmd/smartart/edit.rs @@ -0,0 +1,378 @@ +use super::nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove, sync_text}; +use super::paint::layout; +use super::{ + AutoFit, COLORS, Diagram, DiagramNode, EngineError, Geom, MAX_LEVEL, NO_ID_SPACE, Paragraph, Presentation, Result, STYLES, Session, Shape, + ShapeId, ShapeKind, TextBody, Value, Xfrm, bad, bool_or, current_shapes, is_kind, json, shape_of, str_param, targets, xfrm_of, +}; + +/// The SmartArt group that `id` is, or is inside. +fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + for sh in shapes { + if sh.id == id && sh.diagram.is_some() { + return Some(sh); + } + if let ShapeKind::Group { children, .. } = &sh.kind { + if sh.diagram.is_some() && deckcraft_model::find_shape(children, id).is_some() { + return Some(sh); + } + if let Some(g) = diagram_group(children, id) { + return Some(g); + } + } + } + None +} + +/// The SmartArt graphic the selection is (or is inside), with its outline as the Text Pane shows it. +pub fn selected(st: &crate::DocState) -> Option<(ShapeId, Diagram)> { + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = st.selection.shapes.iter().find_map(|id| diagram_group(shapes, *id))?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +fn target_group(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(cmd, "no slide"))?; + let ids = targets(s, p)?; + ids.iter().find_map(|id| diagram_group(shapes, *id)).map(|g| g.id).ok_or_else(|| bad(cmd, "select a SmartArt graphic")) +} + +/// Children with fresh ids for diagram `d` inside a group whose child space is `area`. +fn regenerate(cmd: &str, doc: &mut Presentation, d: &Diagram, area: Xfrm) -> Result> { + let mut v = layout(d, area); + for sh in &mut v { + sh.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?); + } + Ok(v) +} + +/// Read the diagram (with typed text synced), let `f` change it, and regenerate the group. +fn edit_diagram(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Diagram) -> Result) -> Result { + let gid = target_group(s, p, cmd)?; + s.edit(|doc, sel| { + let g = current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, gid)).cloned().ok_or_else(|| bad(cmd, "no such shape"))?; + let (ShapeKind::Group { children, child }, Some(mut d)) = (g.kind, g.diagram) else { return Err(bad(cmd, "not a SmartArt graphic")) }; + sync_text(&mut d, &children); + let out = f(&mut d)?; + normalize(&mut d.nodes); + d.imported = false; + let kids = regenerate(cmd, doc, &d, child)?; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?; + let sh = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(cmd, "no such shape"))?; + sh.kind = ShapeKind::Group { children: kids, child }; + sh.diagram = Some(d); + sel.shapes = vec![gid]; + sel.text = None; + Ok(out) + }) +} + +fn index(p: &Value, cmd: &str) -> Result { + p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "index is required")) +} + +/// Insert a new SmartArt graphic (`insert.smartArt`). +pub fn insert(s: &mut Session, kind: &str, mut nodes: Vec) -> Result { + let kind = if is_kind(kind) { kind } else { "process" }; + if nodes.is_empty() { + nodes = (0..3).map(|_| DiagramNode { text: "Text".into(), level: 0, picture: None, assistant: false, title: String::new() }).collect(); + } + normalize(&mut nodes); + let d = Diagram { kind: kind.into(), nodes, ..Default::default() }; + let size = s.doc()?.doc.slide_size; + let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6); + let id = s.edit(|doc, sel| { + let children = regenerate("insert.smartArt", doc, &d, area)?; + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("insert.smartArt", NO_ID_SPACE))?); + let g = Shape { + id, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + crate::shapes_mut(doc, sel).ok_or_else(|| EngineError::Other("no slide".into()))?.push(g); + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": id.0})) +} + +pub(super) fn nodes(s: &mut Session, p: &Value) -> Result { + let gid = target_group(s, p, "smartArt.nodes")?; + let g = shape_of(s, gid)?; + let mut d = g.diagram.unwrap_or_default(); + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Ok(json!({"id": gid.0, "kind": d.kind, "nodes": d.nodes})) +} + +pub(super) fn set_nodes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.setNodes"; + let given: Option> = match (p.get("nodes"), p.get("lines").and_then(Value::as_array)) { + (Some(v), _) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(CMD, e.to_string()))?), + (None, Some(a)) => Some(nodes_from_lines(a.iter().filter_map(Value::as_str))), + _ => None, + }; + let one = p.get("index").map(|_| index(p, CMD)).transpose()?; + let text = str_param(p, "text").map(String::from); + let title = str_param(p, "title").map(String::from); + edit_diagram(s, p, CMD, |d| { + if let Some(n) = given { + d.nodes = n; + } + if let (Some(i), Some(t)) = (one, text) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.text = t; + } + if let (Some(i), Some(t)) = (one, title) { + d.nodes.get_mut(i).ok_or_else(|| bad(CMD, "no such node"))?.title = t; + } + Ok(Value::Null) + }) +} + +pub(super) fn add_node(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()); + let pos = str_param(p, "position").unwrap_or("after").to_string(); + let text = str_param(p, "text").unwrap_or("").to_string(); + edit_diagram(s, p, "smartArt.addNode", |d| { + let at = add(&mut d.nodes, i, &pos, &text); + Ok(json!({"index": at})) + }) +} + +pub(super) fn remove_node(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.removeNode"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if remove(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "no such node")) }) +} + +pub(super) fn promote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.promote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if promote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "already at the top level")) }) +} + +pub(super) fn demote(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.demote"; + let i = index(p, CMD)?; + edit_diagram(s, p, CMD, |d| if demote_node(&mut d.nodes, i) { Ok(Value::Null) } else { Err(bad(CMD, "nothing above to demote under")) }) +} + +/// Convert to SmartArt: the selected text shape's paragraphs become the nodes of a new graphic in +/// the shape's place. +pub(super) fn from_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.fromText"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).unwrap_or("list").to_string(); + let id = *targets(s, p)?.first().ok_or_else(|| bad(CMD, "select a text box"))?; + let (area, nodes) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let sh = shapes.iter().find(|x| x.id == id).ok_or_else(|| bad(CMD, "select a text box on the slide"))?; + let body = + sh.text.as_ref().filter(|b| b.paragraphs.iter().any(|p| !p.text().trim().is_empty())).ok_or_else(|| bad(CMD, "the shape has no text"))?; + let mut nodes: Vec = body + .paragraphs + .iter() + .filter(|p| !p.text().trim().is_empty()) + .map(|p| DiagramNode { text: p.text().trim().to_string(), level: p.level.min(MAX_LEVEL), ..Default::default() }) + .collect(); + normalize(&mut nodes); + // A small text box grows to a size a diagram can use, keeping its top left on the slide. + let mut a = xfrm_of(&st.doc, &st.selection, sh); + let size = st.doc.slide_size; + a.w = a.w.max(size.width * 0.5).min(size.width); + a.h = a.h.max(size.height * 0.35).min(size.height); + a.x = a.x.min(size.width - a.w).max(0.0); + a.y = a.y.min(size.height - a.h).max(0.0); + a.rot = 0.0; + (a, nodes) + }; + let d = Diagram { kind: kind.clone(), nodes, ..Default::default() }; + let gid = s.edit(|doc, sel| { + let children = regenerate(CMD, doc, &d, area)?; + let gid = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); + let g = Shape { + id: gid, + name: format!("SmartArt ({kind})"), + xfrm: Some(area), + kind: ShapeKind::Group { children, child: area }, + diagram: Some(d), + ..Default::default() + }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == id).ok_or_else(|| bad(CMD, "no such shape"))?; + // In the text shape's place in the z-order. + shapes[at] = g; + sel.shapes = vec![gid]; + sel.text = None; + Ok(gid) + })?; + Ok(json!({"id": gid.0})) +} + +/// Convert to Text: a text box with the outline, one bulleted paragraph per node. +pub(super) fn to_text(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toText"; + let gid = target_group(s, p, CMD)?; + let (_, d) = selected_for(s, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + // The sizes the graphic's text shows at: its titles' and its bullets' (shrunk to fit included). + let (title_pt, bullet_pt) = { + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + let g = diagram_group(shapes, gid).ok_or_else(|| bad(CMD, "select a SmartArt graphic"))?; + text_sizes(g.children()) + }; + let new = s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let at = shapes.iter().position(|x| x.id == gid).ok_or_else(|| bad(CMD, "the graphic isn't at the top of the slide"))?; + let area = shapes[at].xfrm.unwrap_or_default(); + let mut body = TextBody::default(); + for n in &d.nodes { + let mut para = Paragraph::new(&n.text); + para.level = n.level.min(MAX_LEVEL); + let size = if n.level == 0 { title_pt } else { bullet_pt }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(18.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-14.0); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + body.body.wrap = Some(true); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); + let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?); + let tb = + Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() }; + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + shapes[at] = tb; + sel.shapes = vec![id]; + sel.text = None; + Ok(id) + })?; + Ok(json!({"id": new.0})) +} + +/// Convert to Shapes: keep the drawing as an ordinary group. +pub(super) fn to_shapes(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.toShapes"; + let gid = target_group(s, p, CMD)?; + s.edit(|doc, sel| { + let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(CMD, "no slide"))?; + let g = deckcraft_model::find_shape_mut(shapes, gid).ok_or_else(|| bad(CMD, "no such shape"))?; + g.diagram = None; + g.name = "Group".into(); + sel.shapes = vec![gid]; + Ok(()) + })?; + Ok(Value::Null) +} + +/// The most common title and bullet font sizes in a diagram's shapes (20 and 14 pt when none). +fn text_sizes(children: &[Shape]) -> (f64, f64) { + let mut titles: Vec = vec![]; + let mut bullets: Vec = vec![]; + for c in children { + let Some(b) = &c.text else { continue }; + for p in &b.paragraphs { + let Some(sz) = p.runs.iter().find_map(|r| r.props.size) else { continue }; + if p.props.bullet.is_some() { bullets.push(sz) } else { titles.push(sz) } + } + } + let mode = |v: &mut Vec, d: f64| { + v.sort_by(|a, b| a.total_cmp(b)); + let mut best = (d, 0usize); + let mut k = 0; + while k < v.len() { + let j = v[k..].iter().take_while(|x| (**x - v[k]).abs() < 1e-9).count(); + if j > best.1 { + best = (v[k], j); + } + k += j; + } + best.0 + }; + (mode(&mut titles, 20.0), mode(&mut bullets, 14.0)) +} + +/// The graphic `gid` with its outline (typed text synced). +fn selected_for(s: &Session, gid: ShapeId) -> Option<(ShapeId, Diagram)> { + let st = s.doc().ok()?; + let shapes = current_shapes(&st.doc, &st.selection)?; + let g = diagram_group(shapes, gid)?; + let mut d = g.diagram.clone()?; + if let ShapeKind::Group { children, .. } = &g.kind { + sync_text(&mut d, children); + } + Some((g.id, d)) +} + +pub(super) fn set_picture(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.picture"; + let i = index(p, CMD)?; + let media = if bool_or(p, "reset", false) { + None + } else { + let (name, bytes) = crate::cmd::insert::media_bytes(p, CMD)?; + let ct = crate::cmd::insert::content_type(&name, &bytes); + target_group(s, p, CMD)?; + Some(s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad(CMD, NO_ID_SPACE)))?) + }; + edit_diagram(s, p, CMD, |d| { + // A sub-point's picture is its top-level node's: that's the node the placeholder shows. + let top = (0..=i.min(d.nodes.len().saturating_sub(1))).rev().find(|k| d.nodes.get(*k).is_some_and(|n| n.level == 0)).unwrap_or(i); + let n = d.nodes.get_mut(top).ok_or_else(|| bad(CMD, "no such node"))?; + n.picture = media; + Ok(Value::Null) + }) +} + +pub(super) fn change_style(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.style"; + let v = str_param(p, "style").filter(|k| STYLES.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown style"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.style != v { + d.style_xml = None; + } + d.style = v; + Ok(Value::Null) + }) +} + +pub(super) fn change_colors(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.colors"; + let v = str_param(p, "colors").filter(|k| COLORS.iter().any(|(id, _)| id == k)).ok_or_else(|| bad(CMD, "unknown colours"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.colors != v { + d.colors_xml = None; + } + d.colors = v; + Ok(Value::Null) + }) +} + +pub(super) fn change_layout(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "smartArt.layout"; + let kind = str_param(p, "kind").filter(|k| is_kind(k)).ok_or_else(|| bad(CMD, "unknown layout"))?.to_string(); + edit_diagram(s, p, CMD, |d| { + if d.kind != kind { + // PowerPoint's own layout no longer applies. + d.layout_id.clear(); + d.layout_xml = None; + } + d.kind = kind; + Ok(Value::Null) + }) +} diff --git a/crates/engine/src/cmd/smartart/layout.rs b/crates/engine/src/cmd/smartart/layout.rs new file mode 100644 index 000000000..45b033ae5 --- /dev/null +++ b/crates/engine/src/cmd/smartart/layout.rs @@ -0,0 +1,829 @@ +use super::diagrams::{hierarchy, org_chart}; +use super::nodes::{Item, groups}; +use super::paint::{accent, deco, line_shape, paint, picture_shape, text_shape}; +use super::{Align, ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, Xfrm}; + +#[derive(Clone, Copy)] +struct L<'a> { + d: &'a Diagram, + area: Xfrm, + gs: &'a [Vec], + n: usize, + nf: f64, +} + +impl L<'_> { + fn nodes(&self, g: &[usize]) -> Vec<&DiagramNode> { + g.iter().filter_map(|i| self.d.nodes.get(*i)).collect() + } + + /// A titled shape for top-level group `i`, painted in its accent. + fn titled(&self, out: &mut Vec, preset: &str, x: Xfrm, i: usize, g: &[usize]) { + let mut sh = text_shape(preset, x, &self.nodes(g), true); + paint(self.d, &mut sh, i, false); + out.push((sh, g.to_vec())); + } + + fn arrow(&self, out: &mut Vec, x: Xfrm, rot: f64, i: usize) { + let mut sh = deco("rightArrow", x, rot); + paint(self.d, &mut sh, i, true); + out.push((sh, vec![])); + } +} + +pub(super) fn layout_raw(d: &Diagram, area: Xfrm) -> Vec { + let gs = groups(d); + let n = gs.len().max(1); + let l = L { d, area, gs: &gs, n, nf: n as f64 }; + let mut out: Vec = vec![]; + match d.kind.as_str() { + "hierarchy" => return hierarchy(d, area, false), + "horizontalHierarchy" => return hierarchy(d, area, true), + "orgChart" | "halfCircleOrgChart" | "nameTitleOrgChart" => return org_chart(d, area), + "list" | "hBulletList" => arm_list(&l, &mut out), + "blockList" => arm_block_list(&l, &mut out), + "process" | "chevron" => arm_process(&l, &mut out), + "verticalProcess" => arm_vertical_process(&l, &mut out), + "stepUp" => arm_step_up(&l, &mut out), + "cycle" | "blockCycle" => arm_cycle(&l, &mut out), + "radial" => arm_radial(&l, &mut out), + "pyramid" | "invertedPyramid" => arm_pyramid(&l, &mut out), + "matrix" | "titledMatrix" => arm_matrix(&l, &mut out), + "pictureList" => arm_picture_list(&l, &mut out), + "pictureCaption" | "pictureProcess" => arm_picture_caption(&l, &mut out), + "textCycle" | "continuousCycle" => arm_text_cycle(&l, &mut out), + "segmentedCycle" | "pie" => arm_segmented_cycle(&l, &mut out), + "multidirectionalCycle" => arm_multidirectional_cycle(&l, &mut out), + "cycleMatrix" => arm_cycle_matrix(&l, &mut out), + "segmentedPyramid" => arm_segmented_pyramid(&l, &mut out), + "verticalBoxList" => arm_vertical_box_list(&l, &mut out), + "trapezoidList" | "tableList" => arm_trapezoid_list(&l, &mut out), + "accentProcess" => arm_accent_process(&l, &mut out), + "continuousBlockProcess" => arm_continuous_block_process(&l, &mut out), + "bendingProcess" => arm_bending_process(&l, &mut out), + "linearVenn" | "stackedVenn" => arm_linear_venn(&l, &mut out), + "nestedTarget" => arm_nested_target(&l, &mut out), + "gridMatrix" => arm_grid_matrix(&l, &mut out), + "gear" => arm_gear(&l, &mut out), + "funnel" => arm_funnel(&l, &mut out), + "pictureHList" => arm_picture_h_list(&l, &mut out), + "timeline" => arm_timeline(&l, &mut out), + "venn" => arm_venn(&l, &mut out), + "target" => arm_target(&l, &mut out), + _ => {} + } + out +} + +fn arm_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Vertical / Horizontal Bullet List: a coloured title bar, sub-points in a pale box below. + let vertical = d.kind == "list"; + for (i, g) in gs.iter().enumerate() { + let (cell, bar_h) = if vertical { + (Xfrm::new(area.x, area.y + i as f64 * area.h / nf, area.w, area.h / nf * 0.92), (area.h / nf * 0.92).min(area.h * 0.12).max(18.0)) + } else { + let w = area.w / nf; + (Xfrm::new(area.x + i as f64 * w + w * 0.03, area.y, w * 0.94, area.h), area.h * 0.22) + }; + let has_sub = g.len() > 1; + let bar_h = bar_h.min(cell.h); + l.titled(out, "roundRect", Xfrm::new(cell.x, cell.y, cell.w, bar_h), i, &g[..1]); + if has_sub { + let pad = cell.w * 0.03; + let x = Xfrm::new(cell.x + pad, cell.y + bar_h, cell.w - 2.0 * pad, (cell.h - bar_h).max(1.0)); + let mut sh = text_shape("rect", x, &l.nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + out.push((sh, g[1..].to_vec())); + } + } +} + +fn arm_block_list(l: &L, out: &mut Vec) { + let L { area, gs, n, nf, .. } = *l; + // Basic Block List: equal blocks in rows, as square as the area allows. + let cols = ((nf * area.w / area.h.max(1.0) / 1.6).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (w, h) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (w * 0.9, (h * 0.85).min(w * 0.9 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w) / 2.0; + l.titled(out, "rect", Xfrm::new(x0 + c as f64 * w + (w - bw) / 2.0, area.y + r as f64 * h + (h - bh) / 2.0, bw, bh), i, g); + } +} + +fn arm_process(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + let chevron = d.kind == "chevron"; + let gap = if chevron { -area.w * 0.02 } else { area.w * 0.06 }; + let w = ((area.w - gap * (nf - 1.0)) / nf).max(1.0); + let h = (area.h * 0.5).min(w * if chevron { 0.45 } else { 0.65 }); + let y = area.y + (area.h - h) / 2.0; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + l.titled(out, if chevron { "chevron" } else { "roundRect" }, Xfrm::new(x, y, w, h), i, g); + if !chevron && i + 1 < n { + let aw = gap * 0.7; + l.arrow(out, Xfrm::new(x + w + gap * 0.15, y + h / 2.0 - aw * 0.45, aw, aw * 0.9), 0.0, i); + } + } +} + +fn arm_vertical_process(l: &L, out: &mut Vec) { + let L { area, gs, n, nf, .. } = *l; + let gap = area.h * 0.06; + let h = ((area.h - gap * (nf - 1.0)) / nf).max(1.0); + let w = area.w * 0.6; + let x = area.x + (area.w - w) / 2.0; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * (h + gap); + l.titled(out, "roundRect", Xfrm::new(x, y, w, h), i, g); + if i + 1 < n { + let s = gap * 0.8; + // A right arrow turned to point down. + l.arrow(out, Xfrm::new(area.x + area.w / 2.0 - s / 2.0, y + h + gap * 0.1, s, s), 90.0, i); + } + } +} + +fn arm_step_up(l: &L, out: &mut Vec) { + let L { area, gs, nf, .. } = *l; + // Step Up Process: each step higher than the one before. + let w = area.w / nf; + let h = area.h / (nf + 1.0) * 1.6; + for (i, g) in gs.iter().enumerate() { + let y = area.y + area.h - h - i as f64 * (area.h - h) / (nf - 1.0).max(1.0); + l.titled(out, "rect", Xfrm::new(area.x + i as f64 * w + w * 0.04, y, w * 0.92, h), i, g); + } +} + +fn arm_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + let block = d.kind == "blockCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * if block { 0.75 } else { 0.8 }).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = if block { (dd * 1.3, dd * 0.75) } else { (dd, dd) }; + l.titled(out, if block { "roundRect" } else { "ellipse" }, Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), i, g); + if n > 1 { + // An arrow halfway to the next node, along the circle. + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.35).max(6.0); + let rot = (i as f64 + 0.5) / nf * 360.0; + l.arrow(out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), rot, i); + } + } +} + +fn arm_radial(l: &L, out: &mut Vec) { + let L { area, gs, .. } = *l; + // Basic Radial: the first node in the middle, the others around it with spokes. + let Some((centre, rest)) = gs.split_first() else { return }; + let m = rest.len().max(1) as f64; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = r * 0.75; + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / m * 0.7); + for (k, _) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + let (ex, ey) = (cx + (r - dd / 2.0) * a.cos(), cy + (r - dd / 2.0) * a.sin()); + out.push((line_shape((cx + dc / 2.0 * a.cos(), cy + dc / 2.0 * a.sin()), (ex, ey)), vec![])); + } + l.titled(out, "ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0, centre); + for (k, g) in rest.iter().enumerate() { + let a = (k as f64 / m * 360.0 - 90.0).to_radians(); + l.titled(out, "ellipse", Xfrm::new(cx + r * a.cos() - dd / 2.0, cy + r * a.sin() - dd / 2.0, dd, dd), k + 1, g); + } +} + +fn arm_pyramid(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // One triangle cut into bands; inverted, the wide band is on top. Each band is drawn as + // its own polygon so the sides keep the triangle's slope and the text stays upright. + let inverted = d.kind == "invertedPyramid"; + let h = area.h / nf; + let pw = area.h.min(area.w) * 1.15; + let px = area.x + (area.w - pw) / 2.0; + for (i, g) in gs.iter().enumerate() { + let (a, b) = (i as f64 / nf, (i as f64 + 1.0) / nf); + let (t, bt) = if inverted { (1.0 - a, 1.0 - b) } else { (a, b) }; + let (wt, wb) = (pw * t, pw * bt); + let w = wt.max(wb).max(1.0); + let bh = h * 0.96; + let x = px + (pw - w) / 2.0; + let (it, ib) = ((w - wt) / 2.0, (w - wb) / 2.0); + let path = format!("M {it:.2} 0 L {:.2} 0 L {:.2} {bh:.2} L {ib:.2} {bh:.2} Z", w - it, w - ib); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + i as f64 * h, w, bh), &l.nodes(g), true); + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w, h: bh, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + // Keep the text inside the sloped sides: inset it to a bit past the band's mid width. + // The tip band is a triangle: its text sits at the wide end, inset to the width there. + let tip = wt.min(wb) < 1e-6; + let inset = if tip { w * 0.22 } else { (it + ib) / 2.0 + (it - ib).abs() / 4.0 + 4.0 }; + if let Some(b) = &mut sh.text { + b.body.inset_l = Some(inset); + b.body.inset_r = Some(inset); + if tip { + b.body.anchor = Some(if inverted { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } +} + +fn arm_matrix(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + let titled_m = d.kind == "titledMatrix"; + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let (quads, title): (&[Vec], Option<&Vec>) = + if titled_m { gs.split_first().map(|(t, r)| (r, Some(t))).unwrap_or((&[], None)) } else { (gs, None) }; + for (i, g) in quads.iter().take(4).enumerate() { + let x = Xfrm::new(x0 + (i % 2) as f64 * s / 2.0 + 3.0, y0 + (i / 2) as f64 * s / 2.0 + 3.0, s / 2.0 - 6.0, s / 2.0 - 6.0); + l.titled(out, "roundRect", x, i + usize::from(titled_m), g); + } + if let Some(t) = title { + let c = s * 0.42; + let mut sh = text_shape("roundRect", Xfrm::new(x0 + (s - c) / 2.0, y0 + (s - c * 0.6) / 2.0, c, c * 0.6), &l.nodes(t), true); + paint(d, &mut sh, 0, true); + out.push((sh, t.clone())); + } +} + +fn arm_picture_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Vertical Picture List: a picture square at the left of each row, the text beside it. + let h = area.h / nf; + let bh = h * 0.88; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let s = bh.min(area.w * 0.3); + out.push((picture_shape(d, "rect", Xfrm::new(area.x, y, s, bh), i, g[0]), vec![])); + l.titled(out, "roundRect", Xfrm::new(area.x + s + bh * 0.08, y, (area.w - s - bh * 0.08).max(1.0), bh), i, g); + } +} + +fn arm_picture_caption(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Picture Caption List: a picture over each caption, in rows. Picture Accent Process: one + // row, arrows between the steps. + let process = d.kind == "pictureProcess"; + let cols = if process { n } else { ((nf * area.w / area.h.max(1.0) / 1.2).sqrt().ceil() as usize).clamp(1, n) }; + let rows = n.div_ceil(cols); + let gap = if process { area.w * 0.05 } else { 0.0 }; + let w = (area.w - gap * (cols as f64 - 1.0)) / cols as f64; + let h = area.h / rows as f64; + let bw = if process { w } else { w * 0.9 }; + let ch = (h * 0.9).min(bw * 1.1); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let in_row = if r + 1 == rows { n - r * cols } else { cols }; + let x0 = area.x + (area.w - in_row as f64 * w - gap * (in_row as f64 - 1.0)) / 2.0; + let x = x0 + c as f64 * (w + gap) + (w - bw) / 2.0; + let y = area.y + r as f64 * h + (h - ch) / 2.0; + let ph = ch * 0.62; + out.push(( + picture_shape(d, if process { "ellipse" } else { "rect" }, Xfrm::new(x + (bw - ph.min(bw)) / 2.0, y, ph.min(bw), ph), i, g[0]), + vec![], + )); + l.titled(out, if process { "roundRect" } else { "rect" }, Xfrm::new(x, y + ph + ch * 0.03, bw, ch * 0.35), i, g); + if process && i + 1 < n { + let a = (gap * 0.7).min(ch * 0.2); + l.arrow(out, Xfrm::new(x + bw + (gap - a) / 2.0, y + ph / 2.0 - a / 2.0, a, a), 0.0, i); + } + } +} + +fn arm_text_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Text Cycle: the text alone around a circle, arrows between. Continuous Cycle: boxes on + // a ring. + let text_only = d.kind == "textCycle"; + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.8).min(2.0 * std::f64::consts::PI * r / nf * 0.62); + if !text_only { + let ring = r * 2.0 + dd * 0.3; + let mut sh = deco("donut", Xfrm::new(cx - ring / 2.0, cy - ring / 2.0, ring, ring), 0.0); + sh.geom = Geom::Preset { name: "donut".into(), adj: vec![7000.0] }; + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + let (w, h) = (dd * 1.35, dd * 0.7); + let mut sh = text_shape("roundRect", Xfrm::new(cx + r * a.cos() - w / 2.0, cy + r * a.sin() - h / 2.0, w, h), &l.nodes(g), true); + paint(d, &mut sh, i, false); + if text_only { + sh.fill = Some(Fill::None); + sh.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + sh.effects = None; + let c = ColorRef::scheme(accent(d, i)).with(ColorTransform::LumMod(75_000)); + if let Some(b) = &mut sh.text { + for p in &mut b.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.3).max(6.0); + l.arrow(out, Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.4, s, s * 0.8), (i as f64 + 0.5) / nf * 360.0, i); + } + } + out.push((sh, g.clone())); + } +} + +fn arm_segmented_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Segmented Cycle: a ring cut into one arc per node. Basic Pie: a disc cut into wedges. + // The text sits in a clear box over each segment's middle. + let ring = d.kind == "segmentedCycle"; + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let gap = if ring { 2.0 } else { 0.0 }; + for (i, g) in gs.iter().enumerate() { + // Angles clockwise from 3 o'clock, starting at the top. + let a0 = i as f64 / nf * 360.0 - 90.0 + gap / 2.0; + let a1 = (i as f64 + 1.0) / nf * 360.0 - 90.0 - gap / 2.0; + let mut sh = deco(if ring { "blockArc" } else { "pie" }, Xfrm::new(cx - s / 2.0, cy - s / 2.0, s, s), 0.0); + let ang = |a: f64| (a.rem_euclid(360.0) * 60_000.0).round(); + // blockArc runs from its first angle to its second clockwise; so does pie. + sh.geom = Geom::Preset { + name: if ring { "blockArc" } else { "pie" }.into(), + adj: if ring { vec![ang(a0), ang(a1), 25_000.0] } else { vec![ang(a0), ang(a1)] }, + }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let m = ((a0 + a1) / 2.0).to_radians(); + let rr = if ring { s * 0.4375 } else { s * 0.3 }; + let tw = (s * if ring { 0.22 } else { 0.3 }).min(std::f64::consts::PI * 2.0 * rr / nf * 0.9); + let th = s * 0.16; + let mut t = text_shape("rect", Xfrm::new(cx + rr * m.cos() - tw / 2.0, cy + rr * m.sin() - th / 2.0, tw, th), &l.nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } +} + +fn arm_multidirectional_cycle(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Multidirectional Cycle: nodes around a circle, double-headed arrows between them. + let r = area.h.min(area.w) * 0.36; + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dd = (r * 0.75).min(2.0 * std::f64::consts::PI * r / nf * 0.55); + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 - 90.0).to_radians(); + l.titled(out, "roundRect", Xfrm::new(cx + r * a.cos() - dd * 0.65, cy + r * a.sin() - dd * 0.4, dd * 1.3, dd * 0.8), i, g); + if n > 1 { + let m = ((i as f64 + 0.5) / nf * 360.0 - 90.0).to_radians(); + let s = (dd * 0.5).max(8.0); + let mut sh = + deco("leftRightArrow", Xfrm::new(cx + r * m.cos() - s / 2.0, cy + r * m.sin() - s * 0.2, s, s * 0.4), (i as f64 + 0.5) / nf * 360.0); + paint(d, &mut sh, i, true); + out.push((sh, vec![])); + } + } +} + +fn arm_cycle_matrix(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Cycle Matrix: four quarters of a circle in the middle with the top-level text, each + // quarter's sub-points in a box at its corner. + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let c = s * 0.56; + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let b = s * 0.4; + let bx = Xfrm::new(cx + if sx < 0.0 { -s / 2.0 } else { s / 2.0 - b }, cy + if sy < 0.0 { -s / 2.0 } else { s / 2.0 - b }, b, b); + if g.len() > 1 { + let mut sh = text_shape("roundRect", bx, &l.nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(body) = &mut sh.text { + // Text in the box's outer part, clear of the circle. + body.body.inset_l = Some(if sx > 0.0 { b * 0.35 } else { 6.0 }); + body.body.inset_r = Some(if sx < 0.0 { b * 0.35 } else { 6.0 }); + body.body.anchor = Some(if sy < 0.0 { deckcraft_model::text::Anchor::Top } else { deckcraft_model::text::Anchor::Bottom }); + } + out.push((sh, g[1..].to_vec())); + } + } + for (i, g) in gs.iter().take(4).enumerate() { + let (sx, sy) = ([-1.0, 1.0, 1.0, -1.0][i], [-1.0, -1.0, 1.0, 1.0][i]); + let a0 = [180.0, 270.0, 0.0, 90.0][i] + 1.5; + let mut sh = deco("pie", Xfrm::new(cx - c / 2.0, cy - c / 2.0, c, c), 0.0); + sh.geom = Geom::Preset { name: "pie".into(), adj: vec![a0 * 60_000.0, (a0 + 87.0) * 60_000.0] }; + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + let q = c * 0.3; + let mut t = text_shape("rect", Xfrm::new(cx + sx * c * 0.2 - q / 2.0, cy + sy * c * 0.2 - q / 2.0, q, q), &l.nodes(&g[..1]), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g[..1].to_vec())); + } +} + +fn arm_segmented_pyramid(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Segmented Pyramid: a triangle cut into rows of triangles (1, 3, 5, …), one per node, + // every other one upside down. + let rows = (nf.sqrt().ceil() as usize).max(1); + let s = area.h.min(area.w / 1.15); + let pw = s * 1.15; + let (x0, y0) = (area.x + (area.w - pw) / 2.0, area.y + (area.h - s) / 2.0); + let (tw, th) = (pw / rows as f64, s / rows as f64); + let mut k = 0; + 'rows: for r in 0..rows { + let left = x0 + pw / 2.0 - (r as f64 + 1.0) * tw / 2.0; + for c in 0..(2 * r + 1) { + let Some(g) = gs.get(k) else { break 'rows }; + let x = Xfrm::new(left + c as f64 * tw / 2.0, y0 + r as f64 * th, tw, th); + let mut sh = text_shape("rect", x, &l.nodes(g), true); + // Drawn as a polygon (not a flipped preset) so upside-down text stays upright. + let path = if c % 2 == 1 { + format!("M 0 0 L {tw:.2} 0 L {:.2} {th:.2} Z", tw / 2.0) + } else { + format!("M {:.2} 0 L {tw:.2} {th:.2} L 0 {th:.2} Z", tw / 2.0) + }; + sh.geom = Geom::Custom { + paths: vec![deckcraft_model::CustomPath { w: tw, h: th, d: path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true }], + }; + if let Some(b) = &mut sh.text { + // The text in the triangle's wide half. + b.body.inset_l = Some(tw * 0.22); + b.body.inset_r = Some(tw * 0.22); + if c % 2 == 1 { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + b.body.inset_b = Some(th * 0.35); + } else { + b.body.anchor = Some(deckcraft_model::text::Anchor::Bottom); + b.body.inset_t = Some(th * 0.35); + } + } + paint(d, &mut sh, k, false); + out.push((sh, g.clone())); + k += 1; + } + } +} + +fn arm_vertical_box_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Vertical Box List: a pale box per node with its sub-points, its title on a tab + // overlapping the box's top left. + let h = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let y = area.y + i as f64 * h; + let tab_h = (h * 0.3).min(40.0); + let mut bx = text_shape("rect", Xfrm::new(area.x, y + tab_h / 2.0, area.w, h * 0.9 - tab_h / 2.0), &l.nodes(&g[1..]), false); + paint(d, &mut bx, i, true); + if let Some(b) = &mut bx.text { + b.body.inset_t = Some(tab_h * 0.6); + } + out.push((bx, g[1..].to_vec())); + l.titled(out, "roundRect", Xfrm::new(area.x + area.w * 0.05, y, area.w * 0.6, tab_h), i, &g[..1]); + } +} + +fn arm_trapezoid_list(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Trapezoid List: a column per node, each a trapezoid with its sub-points. Table List: the + // first node as a header bar over a column per other node. + let table = d.kind == "tableList"; + let (head, cols): (Option<&Vec>, &[Vec]) = + if table { gs.split_first().map(|(h, r)| (Some(h), r)).unwrap_or((None, &[])) } else { (None, gs) }; + let top = if head.is_some() { area.h * 0.28 } else { 0.0 }; + if let Some(h) = head { + l.titled(out, "rect", Xfrm::new(area.x, area.y, area.w, top * 0.9), 0, h); + } + let m = cols.len().max(1) as f64; + let w = area.w / m; + for (k, g) in cols.iter().enumerate() { + let i = k + usize::from(table); + let x = Xfrm::new(area.x + k as f64 * w + w * 0.02, area.y + top, w * 0.96, area.h - top); + let mut sh = text_shape(if table { "rect" } else { "trapezoid" }, x, &l.nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + if !table { + b.body.inset_l = Some(x.w * 0.2); + b.body.inset_r = Some(x.w * 0.2); + b.body.inset_t = Some(x.h * 0.08); + } + } + paint(d, &mut sh, i, table); + out.push((sh, g.clone())); + } +} + +fn arm_accent_process(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Accent Process: a title box per step with its sub-points in a pale box below and to the + // right, arrows between the titles. + let gap = area.w * 0.05; + let w = (area.w - gap * (nf - 1.0)) / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * (w + gap); + let th = area.h * 0.22; + if g.len() > 1 { + let mut sh = text_shape("rect", Xfrm::new(x + w * 0.12, area.y + th * 0.7, w * 0.88, area.h - th * 0.7), &l.nodes(&g[1..]), false); + paint(d, &mut sh, i, true); + if let Some(b) = &mut sh.text { + b.body.inset_t = Some(th * 0.45); + } + out.push((sh, g[1..].to_vec())); + } + l.titled(out, "roundRect", Xfrm::new(x, area.y, w * 0.85, th), i, &g[..1]); + if i + 1 < n { + let a = gap * 0.8; + l.arrow(out, Xfrm::new(x + w + (gap - a) / 2.0 - w * 0.07, area.y + th / 2.0 - a / 2.0, a, a), 0.0, i); + } + } +} + +fn arm_continuous_block_process(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Continuous Block Process: one big pale arrow behind a row of boxes. + let mut big = deco("rightArrow", Xfrm::new(area.x, area.y + area.h * 0.15, area.w, area.h * 0.7), 0.0); + paint(d, &mut big, 0, true); + out.push((big, vec![])); + let w = area.w * 0.85 / nf; + let h = area.h * 0.32; + for (i, g) in gs.iter().enumerate() { + let x = area.x + area.w * 0.02 + i as f64 * w; + l.titled(out, "roundRect", Xfrm::new(x + w * 0.05, area.y + (area.h - h) / 2.0, w * 0.9, h), i, g); + } +} + +fn arm_bending_process(l: &L, out: &mut Vec) { + let L { area, gs, n, nf, .. } = *l; + // Basic Bending Process: rows of boxes read left to right, wrapping, arrows between. + let cols = ((nf * area.w / area.h.max(1.0) / 1.5).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols); + let (cw, rh) = (area.w / cols as f64, area.h / rows as f64); + let (bw, bh) = (cw * 0.72, (rh * 0.75).min(cw * 0.72 * 0.6)); + for (i, g) in gs.iter().enumerate() { + let (c, r) = (i % cols, i / cols); + let x = area.x + c as f64 * cw; + let y = area.y + r as f64 * rh + (rh - bh) / 2.0; + l.titled(out, "rect", Xfrm::new(x, y, bw, bh), i, g); + if i + 1 < n { + let a = (cw - bw) * 0.7; + if c + 1 < cols { + l.arrow(out, Xfrm::new(x + bw + (cw - bw - a) / 2.0, y + bh / 2.0 - a / 2.0, a, a), 0.0, i); + } else { + // The bend: down from the row's last box towards the next row's first. + let a = (rh - bh).min(a * 1.5).max(6.0); + l.arrow(out, Xfrm::new(x + bw / 2.0 - a / 2.0, y + bh + (rh - bh - a) / 2.0, a, a), 90.0, i); + } + } + } +} + +fn arm_linear_venn(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Linear Venn: overlapping circles in a row. Stacked Venn: circles nested from the bottom, + // the text near each one's top. + let linear = d.kind == "linearVenn"; + if linear { + let dc = (area.w / (nf * 0.75 + 0.25)).min(area.h); + let step = dc * 0.75; + let x0 = area.x + (area.w - (step * (nf - 1.0) + dc)) / 2.0; + for (i, g) in gs.iter().enumerate() { + let mut sh = text_shape("ellipse", Xfrm::new(x0 + i as f64 * step, area.y + (area.h - dc) / 2.0, dc, dc), &l.nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(55_000)); + } + out.push((sh, g.clone())); + } + } else { + let s = area.h.min(area.w); + let (cx, bottom) = (area.x + area.w / 2.0, area.y + (area.h + s) / 2.0); + for (i, _) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, bottom - dc, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + // Each circle's text in the band between its top and the next circle's. + let band = s / nf; + for (i, g) in gs.iter().enumerate() { + let dc = s * (1.0 - i as f64 / nf); + let top = bottom - dc; + let h = if i + 1 < n { band } else { dc }; + let w = dc * if i + 1 < n { 0.6 } else { 0.7 }; + let mut t = text_shape("rect", Xfrm::new(cx - w / 2.0, top + if i + 1 < n { band * 0.05 } else { 0.0 }, w, h * 0.9), &l.nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } + } +} + +fn arm_nested_target(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Nested Target: rounded boxes nested inside each other, the text on each one's top band. + let band_x = area.w / 2.0 / nf * 0.6; + let band_y = area.h / nf * 0.9 / 2.0; + for (i, _) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let mut sh = deco("roundRect", Xfrm::new(area.x + dx, area.y + dy, area.w - 2.0 * dx, area.h - 2.0 * dy), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + for (i, g) in gs.iter().enumerate() { + let (dx, dy) = (band_x * i as f64, band_y * i as f64); + let h = if i + 1 < n { band_y } else { area.h - 2.0 * dy }; + let mut t = + text_shape("rect", Xfrm::new(area.x + dx + band_x * 0.3, area.y + dy, (area.w - 2.0 * dx - band_x * 0.6).max(1.0), h), &l.nodes(g), true); + paint(d, &mut t, i, false); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + t.effects = None; + out.push((t, g.clone())); + } +} + +fn arm_grid_matrix(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Grid Matrix: four quadrants on crossed axes. + let s = area.h.min(area.w); + let (x0, y0) = (area.x + (area.w - s) / 2.0, area.y + (area.h - s) / 2.0); + let t = s * 0.06; + for (preset, x) in [("leftRightArrow", Xfrm::new(x0, y0 + (s - t) / 2.0, s, t)), ("upDownArrow", Xfrm::new(x0 + (s - t) / 2.0, y0, t, s))] { + let mut sh = deco(preset, x, 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + } + let q = s / 2.0 - t * 1.2; + for (i, g) in gs.iter().take(4).enumerate() { + let (c, r) = ((i % 2) as f64, (i / 2) as f64); + let x = Xfrm::new(x0 + c * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - c), y0 + r * (s / 2.0 + t * 0.7) + t * 0.5 * (1.0 - r), q, q); + l.titled(out, "rect", x, i, g); + } +} + +fn arm_gear(l: &L, out: &mut Vec) { + let L { area, gs, .. } = *l; + // Gear: up to three interlocking gears, the first the biggest. + let s = area.h.min(area.w * 0.62); + let (x0, y0) = (area.x + (area.w - s / 0.62) / 2.0, area.y + (area.h - s) / 2.0); + let spots = [(0.0, 0.18, 0.66, "gear9"), (0.5, 0.0, 0.48, "gear6"), (0.56, 0.48, 0.42, "gear6")]; + for (i, g) in gs.iter().take(3).enumerate() { + let (fx, fy, fs, preset) = spots[i]; + let z = s * fs; + l.titled(out, preset, Xfrm::new(x0 + fx * s * 1.3, y0 + fy * s, z, z), i, g); + } +} + +fn arm_funnel(l: &L, out: &mut Vec) { + let L { d, area, gs, .. } = *l; + // Funnel: the ideas poured in at the top (up to three), the last one coming out below. + let s = area.h.min(area.w); + let (cx, y0) = (area.x + area.w / 2.0, area.y); + let fw = s * 0.8; + let fh = s * 0.62; + let mut f = deco("funnel", Xfrm::new(cx - fw / 2.0, y0, fw, fh), 0.0); + paint(d, &mut f, 0, true); + out.push((f, vec![])); + let (inside, last) = match gs.split_last() { + Some((l, r)) if !r.is_empty() => (r, Some(l)), + _ => (gs, None), + }; + let k = inside.len().min(3); + let dc = fw * 0.27; + let spots = [(0.0, 0.04), (-0.2, 0.3), (0.2, 0.3)]; + for (i, g) in inside.iter().take(3).enumerate() { + let (dx, dy) = if k == 1 { (0.0, 0.15) } else { spots[i] }; + l.titled(out, "ellipse", Xfrm::new(cx + dx * fw - dc / 2.0, y0 + dy * fh + fh * 0.05, dc, dc), i, g); + } + if let Some(g) = last { + let a = s * 0.12; + let mut ar = deco("downArrow", Xfrm::new(cx - a / 2.0, y0 + fh, a, a * 1.2), 0.0); + paint(d, &mut ar, k, true); + out.push((ar, vec![])); + l.titled(out, "roundRect", Xfrm::new(cx - fw * 0.35, y0 + fh + a * 1.3, fw * 0.7, (s - fh - a * 1.3).max(10.0)), k, g); + } +} + +fn arm_picture_h_list(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Horizontal Picture List: a column per node, a big picture over its text. + let w = area.w / nf; + for (i, g) in gs.iter().enumerate() { + let x = area.x + i as f64 * w + w * 0.04; + let bw = w * 0.92; + let ph = area.h * 0.55; + out.push((picture_shape(d, "rect", Xfrm::new(x, area.y, bw, ph), i, g[0]), vec![])); + let mut sh = text_shape("rect", Xfrm::new(x, area.y + ph, bw, area.h - ph), &l.nodes(g), true); + if let Some(b) = &mut sh.text { + b.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + paint(d, &mut sh, i, false); + out.push((sh, g.clone())); + } +} + +fn arm_timeline(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Basic Timeline: an arrow across the middle, a dot per node on it, the text above and + // below in turn. + let mid = area.y + area.h / 2.0; + let bar = (area.h * 0.08).max(6.0); + let mut sh = deco("rightArrow", Xfrm::new(area.x, mid - bar, area.w, bar * 2.0), 0.0); + paint(d, &mut sh, 0, true); + out.push((sh, vec![])); + let w = area.w * 0.92 / nf; + for (i, g) in gs.iter().enumerate() { + let cx = area.x + w * (i as f64 + 0.5); + let dot = bar * 1.4; + let mut dsh = deco("ellipse", Xfrm::new(cx - dot / 2.0, mid - dot / 2.0, dot, dot), 0.0); + paint(d, &mut dsh, i, false); + out.push((dsh, vec![])); + let th = area.h / 2.0 - bar * 1.6; + let y = if i % 2 == 0 { area.y } else { mid + bar * 1.6 }; + let mut t = text_shape("rect", Xfrm::new(cx - w * 0.48, y, w * 0.96, th.max(1.0)), &l.nodes(g), true); + if let Some(b) = &mut t.text { + b.body.anchor = Some(if i % 2 == 0 { deckcraft_model::text::Anchor::Bottom } else { deckcraft_model::text::Anchor::Top }); + } + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + out.push((t, g.clone())); + } +} + +fn arm_venn(l: &L, out: &mut Vec) { + let L { d, area, gs, n, nf } = *l; + // Basic Venn: see-through circles overlapping in a ring (two or three in a row/triangle). + let s = area.h.min(area.w); + let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0); + let dc = if n == 1 { s } else { s * 0.62 }; + let r = if n == 1 { 0.0 } else { (s - dc) / 2.0 }; + for (i, g) in gs.iter().enumerate() { + let a = (i as f64 / nf * 360.0 + if n == 2 { 180.0 } else { -90.0 }).to_radians(); + let mut sh = text_shape("ellipse", Xfrm::new(cx + r * a.cos() - dc / 2.0, cy + r * a.sin() - dc / 2.0, dc, dc), &l.nodes(g), true); + paint(d, &mut sh, i, false); + if let Some(Fill::Solid { color: c }) = &mut sh.fill { + *c = c.clone().with(ColorTransform::Alpha(50_000)); + } + if let Some(b) = &mut sh.text { + // Text towards the circle's outer side, clear of the overlap. + let p = dc * 0.36; + let (dx, dy) = (a.cos() * p, a.sin() * p); + b.body.inset_l = Some((7.2 + dx).max(2.0)); + b.body.inset_r = Some((7.2 - dx).max(2.0)); + b.body.inset_t = Some((3.6 + dy).max(2.0)); + b.body.inset_b = Some((3.6 - dy).max(2.0)); + } + out.push((sh, g.clone())); + } +} + +fn arm_target(l: &L, out: &mut Vec) { + let L { d, area, gs, nf, .. } = *l; + // Basic Target: nested rings, the outermost first, each ring's text in a box beside it + // with a line from the ring. + let s = area.h.min(area.w * 0.5); + let (cx, cy) = (area.x + s / 2.0, area.y + area.h / 2.0); + let band = s / 2.0 / nf; + for (i, _) in gs.iter().enumerate() { + let dc = s - 2.0 * band * i as f64; + let mut sh = deco("ellipse", Xfrm::new(cx - dc / 2.0, cy - dc / 2.0, dc, dc), 0.0); + paint(d, &mut sh, i, false); + out.push((sh, vec![])); + } + let tx = area.x + s + area.w * 0.06; + let rh = area.h / nf; + for (i, g) in gs.iter().enumerate() { + let ty = area.y + rh * (i as f64 + 0.5); + // The point on ring i's band level with the text, else its top. + let ry = cy - s / 2.0 + band * (i as f64 + 0.5); + out.push((line_shape((cx, ry.min(ty)), (tx, ty)), vec![])); + let mut t = text_shape("rect", Xfrm::new(tx, ty - rh * 0.45, (area.x + area.w - tx).max(1.0), rh * 0.9), &l.nodes(g), true); + paint(d, &mut t, i, true); + t.fill = Some(Fill::None); + t.line = Some(Line { fill: Some(Fill::None), ..Default::default() }); + if let Some(b) = &mut t.text { + for p in &mut b.paragraphs { + p.props.align = Some(Align::Left); + } + } + out.push((t, g.clone())); + } +} diff --git a/crates/engine/src/cmd/smartart/nodes.rs b/crates/engine/src/cmd/smartart/nodes.rs new file mode 100644 index 000000000..b87edd79e --- /dev/null +++ b/crates/engine/src/cmd/smartart/nodes.rs @@ -0,0 +1,146 @@ +use super::paint::layout_items; +use super::{Diagram, DiagramNode, MAX_LEVEL, MAX_SHAPES, Shape, Xfrm}; + +/// Nodes from text lines: each leading tab is one outline level. +pub fn nodes_from_lines<'a>(lines: impl IntoIterator) -> Vec { + lines + .into_iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let tabs = l.chars().take_while(|c| *c == '\t').count(); + DiagramNode { + text: l.trim().to_string(), + level: u8::try_from(tabs).unwrap_or(MAX_LEVEL).min(MAX_LEVEL), + picture: None, + assistant: false, + title: String::new(), + } + }) + .collect() +} + +/// Levels made consistent: the first node is top level and no node is more than one level +/// deeper than the node before it. +pub fn normalize(nodes: &mut [DiagramNode]) { + let mut prev: Option = None; + for n in nodes.iter_mut() { + n.level = match prev { + None => 0, + Some(p) => n.level.min(p.saturating_add(1)).min(MAX_LEVEL), + }; + prev = Some(n.level); + } +} + +/// Index one past the last descendant of node `i`. +fn subtree_end(nodes: &[DiagramNode], i: usize) -> usize { + let lv = nodes.get(i).map(|n| n.level).unwrap_or(0); + let mut j = i + 1; + while nodes.get(j).is_some_and(|n| n.level > lv) { + j += 1; + } + j +} + +/// Add a node next to node `index` (PowerPoint's Add Shape After/Before/Below/Above). Returns the +/// new node's index. +pub fn add(nodes: &mut Vec, index: Option, position: &str, text: &str) -> usize { + let Some(i) = index.filter(|i| *i < nodes.len()) else { + nodes.push(DiagramNode { text: text.into(), level: 0, picture: None, assistant: false, title: String::new() }); + return nodes.len() - 1; + }; + let lv = nodes[i].level; + let (at, level) = match position { + "before" => (i, lv), + "below" => (subtree_end(nodes, i), (lv + 1).min(MAX_LEVEL)), + "above" => { + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level = (n.level + 1).min(MAX_LEVEL); + } + (i, lv) + } + "assistant" => (i + 1, (lv + 1).min(MAX_LEVEL)), + _ => (subtree_end(nodes, i), lv), + }; + nodes.insert(at, DiagramNode { text: text.into(), level, picture: None, assistant: position == "assistant", title: String::new() }); + at +} + +/// Remove node `i`; its sub-points move up one level. +pub fn remove(nodes: &mut Vec, i: usize) -> bool { + if i >= nodes.len() { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i + 1..end] { + n.level = n.level.saturating_sub(1); + } + nodes.remove(i); + normalize(nodes); + true +} + +/// Move node `i` and its sub-points one level up. False when it is already top level. +pub fn promote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + if nodes.get(i).is_none_or(|n| n.level == 0) { + return false; + } + let end = subtree_end(nodes, i); + for n in &mut nodes[i..end] { + n.level -= 1; + } + true +} + +/// Move node `i` and its sub-points one level down, under the node before it. False when there is +/// nothing to go under. +pub fn demote_node(nodes: &mut [DiagramNode], i: usize) -> bool { + let (Some(prev), Some(cur)) = (i.checked_sub(1).and_then(|p| nodes.get(p)), nodes.get(i)) else { return false }; + if cur.level > prev.level || cur.level >= MAX_LEVEL { + return false; + } + let end = subtree_end(nodes, i); + if nodes[i..end].iter().any(|n| n.level >= MAX_LEVEL) { + return false; + } + for n in &mut nodes[i..end] { + n.level += 1; + } + true +} + +/// A generated shape and the nodes whose text it shows (none for arrows and lines). +pub(super) type Item = (Shape, Vec); + +/// Top-level nodes, each with its sub-points. +pub(super) fn groups(d: &Diagram) -> Vec> { + let mut out: Vec> = vec![]; + for (i, n) in d.nodes.iter().enumerate() { + match out.last_mut() { + Some(g) if n.level > 0 => g.push(i), + _ => out.push(vec![i]), + } + } + out.truncate(MAX_SHAPES); + out +} + +/// Read text typed into the diagram's shapes back into its nodes. +pub(super) fn sync_text(d: &mut Diagram, children: &[Shape]) { + if d.imported { + return; + } + let map: Vec> = layout_items(d, Xfrm::new(0.0, 0.0, 100.0, 100.0)).into_iter().map(|(_, m)| m).collect(); + for (shape, idx) in children.iter().zip(map) { + let Some(body) = &shape.text else { continue }; + if idx.is_empty() || body.paragraphs.len() != idx.len() { + continue; + } + for (p, i) in body.paragraphs.iter().zip(idx) { + if let Some(n) = d.nodes.get_mut(i) { + n.text = p.text(); + } + } + } +} diff --git a/crates/engine/src/cmd/smartart/paint.rs b/crates/engine/src/cmd/smartart/paint.rs new file mode 100644 index 000000000..fc75a1f90 --- /dev/null +++ b/crates/engine/src/cmd/smartart/paint.rs @@ -0,0 +1,266 @@ +use super::layout::layout_raw; +use super::nodes::Item; +use super::{ + Align, AutoFit, ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Gradient, GradientShape, GradientStop, Line, MAX_LEVEL, + Paragraph, SchemeSlot, Shadow, Shape, ShapeStyle, TextBody, Xfrm, +}; + +fn slot(i: usize) -> SchemeSlot { + [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6] +} + +/// The accent shape `i` is drawn in under Change Colors: `colorful` cycles the accents, `accentN` +/// keeps one. +pub(super) fn accent(d: &Diagram, i: usize) -> SchemeSlot { + let digit = |s: &str, k: usize| s.chars().nth(k).and_then(|c| c.to_digit(10)).map(|n| n as usize); + if let Some(r) = d.colors.strip_prefix("range") + && let Some(a @ 1..=6) = digit(r, 0) + { + // Colorful Range: alternate between two neighbouring accents. + return slot(a - 1 + i % 2); + } + match d.colors.strip_prefix("accent").and_then(|r| digit(r, 0)) { + Some(n @ 1..=6) => slot(n - 1), + _ => slot(i), + } +} + +/// Gradient Range: each shape a step darker than the one before (the steps cycle). +pub(super) fn shade(d: &Diagram, i: usize) -> Option { + d.colors.ends_with("Grad").then(|| ColorTransform::LumMod(100_000 - (i % 4) as i32 * 15_000)) +} + +/// The 3-D XML (`scene3d` + `sp3d`) for a 3-D SmartArt Style: a top bevel (`w`, `h` in points) +/// lit by the three-point rig. +fn three_d(bevel: &str, w: f64, h: f64, camera: &str) -> String { + format!( + "", + (w * 12700.0).round() as i64, + (h * 12700.0).round() as i64 + ) +} + +/// Brick Scene's camera: the diagram's shapes seen from above and to the side. +pub const BRICK_CAMERA: &str = "isometricOffAxis2Top"; + +/// Whether `style` is one of the 3-D SmartArt Styles. +pub fn is_3d(style: &str) -> bool { + matches!(style, "polished" | "inset" | "cartoon" | "powder" | "brick") +} + +/// Fill, outline, text colour and effects for the SmartArt style. `light` shapes (the bullet +/// boxes of the bullet lists) are a pale tint of the accent with dark text. +pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) { + let a = accent(d, i); + let base = match shade(d, i) { + Some(t) => ColorRef::scheme(a).with(t), + None => ColorRef::scheme(a), + }; + let tint = |pct: i32| ColorRef::scheme(a).with(ColorTransform::LumMod(100_000 - pct * 1000)).with(ColorTransform::LumOff(pct * 1000)); + let dark_text = light || matches!(d.style.as_str(), "outline" | "powder"); + sh.style = Some(ShapeStyle::accent(a)); + match d.style.as_str() { + _ if light => { + sh.fill = Some(Fill::solid(tint(80))); + sh.line = Some(Line::solid(tint(60), 1.0)); + } + "outline" => { + sh.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Lt1))); + sh.line = Some(Line::solid(base.clone(), 2.25)); + } + "subtle" => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(4.0, 3.0)), ..Default::default() }); + } + "polished" | "inset" | "cartoon" | "powder" | "brick" => { + let (bevel, bw, line, fill) = match d.style.as_str() { + "polished" => ("circle", 12.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 0.75)), base.clone()), + "inset" => ("relaxedInset", 10.0, None, base.clone()), + "cartoon" => ("softRound", 8.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Tx1), 2.0)), base.clone()), + "powder" => ("convex", 14.0, None, tint(40)), + _ => ("slope", 6.0, Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)), base.clone()), + }; + sh.fill = Some(Fill::solid(fill)); + sh.line = Some(line.unwrap_or_else(|| Line { fill: Some(Fill::None), ..Default::default() })); + sh.effects = Some(Effects { + outer_shadow: (d.style != "inset").then(|| shadow(6.0, 4.0)), + bevel: Some(bevel.into()), + raw3d: Some(three_d(bevel, bw, bw * 0.6, if d.style == "brick" { BRICK_CAMERA } else { "orthographicFront" })), + ..Default::default() + }); + } + "intense" => { + sh.fill = Some(Fill::Gradient(Gradient { + stops: vec![ + GradientStop { pos: 0.0, color: tint(30) }, + GradientStop { pos: 1.0, color: base.clone().with(ColorTransform::LumMod(75_000)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + tile: [0.0; 4], + })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); + sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); + } + _ => { + sh.fill = Some(Fill::solid(base.clone())); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + } + if let Some(body) = &mut sh.text { + let c = if dark_text { ColorRef::scheme(SchemeSlot::Tx1) } else { ColorRef::scheme(SchemeSlot::Lt1) }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + r.props.fill = Some(Fill::solid(c.clone())); + } + } + } +} + +fn shadow(blur: f64, dist: f64) -> Shadow { + Shadow { + color: ColorRef::rgb(deckcraft_model::Rgba { r: 0, g: 0, b: 0, a: 255 }).with(ColorTransform::Alpha(40_000)), + blur, + dist, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +/// A shape with text: the first line as the title (centred, larger), the rest as bullets. With +/// `title` false every line is a bullet (the bullet boxes of the bullet lists). +pub(super) fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape { + let mut body = TextBody::default(); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); + let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0); + for (k, n) in lines.iter().enumerate() { + let mut para = Paragraph::new(&n.text); + let head = title && k == 0; + if head { + para.props.align = Some(Align::Center); + } else { + para.level = n.level.saturating_sub(top).saturating_sub(1).min(MAX_LEVEL); + para.props.align = Some(Align::Left); + para.props.bullet = Some(deckcraft_model::text::Bullet::Char { char: "•".into() }); + para.props.margin_left = Some(12.0 * (para.level as f64 + 1.0)); + para.props.indent = Some(-10.0); + } + let size = if head { 20.0 } else { 14.0 }; + for r in &mut para.runs { + r.props.size = Some(size); + } + para.end_props.size = Some(size); + body.paragraphs.push(para); + } + if body.paragraphs.is_empty() { + body.paragraphs.push(Paragraph::new("")); + } + if !title { + body.body.anchor = Some(deckcraft_model::text::Anchor::Top); + } + Shape { name: "SmartArt shape".into(), xfrm: Some(x), geom: Geom::preset(preset), text: Some(body), ..Default::default() } +} + +/// A plain shape with no text (arrows between steps). +pub(super) fn deco(preset: &str, x: Xfrm, rot: f64) -> Shape { + let mut x = x; + x.rot = rot; + Shape { name: "SmartArt arrow".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() } +} + +pub(super) fn line_shape(a: (f64, f64), b: (f64, f64)) -> Shape { + let mut x = Xfrm::new(a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()); + x.flip_h = (b.0 < a.0) != (b.1 < a.1); + Shape { + name: "SmartArt connector".into(), + xfrm: Some(x), + geom: Geom::preset("line"), + fill: Some(Fill::None), + line: Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::LumMod(60_000)), 1.25)), + ..Default::default() + } +} + +/// The diagram's shapes laid out in `area` (ids not yet assigned). +pub fn layout(d: &Diagram, area: Xfrm) -> Vec { + layout_items(d, area).into_iter().map(|(s, _)| s).collect() +} + +/// Lay out `d`, painted with its style and colours, each shape with the nodes it shows; text is +/// shrunk per shape so its longest word fits on a line. +pub(super) fn layout_items(d: &Diagram, area: Xfrm) -> Vec { + let mut v = layout_raw(d, area); + for (sh, _) in &mut v { + fit_words(sh); + } + v +} + +/// Rough advance of a character as a fraction of the font size (a sans text face). +const CHAR_W: f64 = 0.6; + +/// Smallest font scale shrinking to fit goes to. +pub(super) const MIN_SCALE: f64 = 0.25; + +/// Shrink the shape's text (all paragraphs together, as PowerPoint's per-shape font size does) so +/// no word is wider than its line and would break mid-word. Overflow in height is left to the +/// body's shrink-on-overflow autofit. +fn fit_words(sh: &mut Shape) { + let Some(w) = sh.xfrm.map(|x| x.w) else { return }; + // Round shapes keep their text in the inscribed rectangle (about 71% of the width). + let text_w = match sh.geom.preset_name() { + Some("ellipse" | "gear6" | "gear9") => w * 0.71, + _ => w, + }; + let Some(body) = &mut sh.text else { return }; + let avail = text_w - body.body.inset_l.unwrap_or(7.2) - body.body.inset_r.unwrap_or(7.2); + let mut scale: f64 = 1.0; + for p in &body.paragraphs { + let line = avail - p.props.margin_left.unwrap_or(0.0).max(0.0); + for r in &p.runs { + let size = r.props.size.unwrap_or(18.0); + let word = r.text.split_whitespace().map(|t| t.chars().count()).max().unwrap_or(0) as f64; + let need = word * size * CHAR_W; + if need > 0.0 { + scale = scale.min(line.max(0.0) / need); + } + } + } + let scale = scale.max(MIN_SCALE); + if scale >= 0.999 { + return; + } + let shrink = |v: &mut Option| { + if let Some(x) = v { + *x = (*x * scale * 2.0).floor() / 2.0; + } + }; + for p in &mut body.paragraphs { + for r in &mut p.runs { + shrink(&mut r.props.size); + } + shrink(&mut p.end_props.size); + shrink(&mut p.props.margin_left); + if let Some(i) = &mut p.props.indent { + *i *= scale; + } + } +} + +/// A picture placeholder for node `node`: its picture when it has one, else a pale box. +pub(super) fn picture_shape(d: &Diagram, preset: &str, x: Xfrm, i: usize, node: usize) -> Shape { + let mut sh = Shape { name: "SmartArt picture".into(), xfrm: Some(x), geom: Geom::preset(preset), ..Default::default() }; + paint(d, &mut sh, i, true); + if let Some(m) = d.nodes.get(node).and_then(|n| n.picture) { + sh.fill = Some(Fill::Picture(deckcraft_model::style::PictureFill { media: m, ..Default::default() })); + sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.5)); + } + sh +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fc4c81d52..6f85df870 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -15,6 +15,27 @@ use super::{ }; use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo}; +mod autocorrect; +mod caret; +mod editing; +mod formatting; +mod ime; +mod layout; + +use autocorrect::autocorrect_options; +pub use autocorrect::{AUTOCORRECT, AutoList, correct_word}; +pub(crate) use caret::{apply_leading_tabs, delete_dir}; +use caret::{delete, delete_surrounding, get, insert, move_caret, select, select_all, select_para, select_word, set}; +use editing::{edit, exit}; +pub(crate) use editing::{insert_field, insert_text, paste_body}; +pub use formatting::format_range; +use formatting::{fonts_used, replace_font}; +pub(crate) use formatting::{format_body, format_para, format_run, link_text}; +pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines}; +use ime::{commit, compose}; +pub(crate) use layout::ctx_for; +pub use layout::{caret_color, caret_line, fit_text_box, layout_for, text_frame, text_pos_at}; + /// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands /// with their parameter docs and enabled predicates. pub fn specs() -> Vec { @@ -140,240 +161,6 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti Some(sh.text.get_or_insert_with(TextBody::default)) } -/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query. -/// -/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the -/// engine answer without copying a whole body or document. -pub const IME_SURROUNDING_MAX_BYTES: usize = 4000; - -/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8 -/// byte offsets. The current input-method composition (pre-edit) is removed from both the text -/// and the offsets. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ImeSurrounding { - pub text: String, - pub cursor: usize, - pub anchor: usize, -} - -/// How a soft line break reads in the IME view: a newline, like a paragraph break. -/// -/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would -/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines -/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change. -fn ime_char(ch: char) -> char { - if ch == '\u{b}' { '\n' } else { ch } -} - -/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`. -fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { - p.runs - .iter() - .flat_map(|run| run.content().chars()) - .enumerate() - .filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))) -} - -/// The same characters, newest first, without walking the paragraph from its start. -fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { - let mut starts = Vec::with_capacity(p.runs.len()); - let mut at = 0usize; - for r in &p.runs { - let n = r.char_len(); - starts.push((at, n)); - at += n; - } - p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| { - let content = r.content(); - content.chars().rev().enumerate().filter_map(move |(k, ch)| { - let i = start + n - 1 - k; - (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))) - }) - }) -} - -/// The composition's `(paragraph, start, end)` character range, when it is inside the body. -fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> { - let c = c?; - let para = body.paragraphs.get(c.start.0)?; - let start = c.start.1.min(para.char_len()); - let end = start.saturating_add(c.len).min(para.char_len()); - (start < end).then_some((c.start.0, start, end)) -} - -/// Return the composition character range only when its paragraph index equals `pi`. -fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> { - comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e)) -} - -/// Clip a model position to the body. An empty body only has position `(0, 0)`. -fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> { - if body.paragraphs.is_empty() { - return (pos == (0, 0)).then_some((0, 0)); - } - body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len()))) -} - -/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole -/// characters), stopping at the end of the body. Returns the position reached and the characters -/// and bytes consumed. -/// -/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks -/// that paragraph's runs from their start: runs do not cache character lengths, and a position is -/// a character index. A megabyte paragraph therefore costs time proportional to the caret's -/// character offset, even though the copied surrounding text stays within `limit`. -fn walk_forward( - body: &TextBody, - comp: Option<(usize, usize, usize)>, - from: (usize, usize), - limit: usize, - bytes: bool, -) -> ((usize, usize), usize, usize) { - let (mut pi, mut at) = from; - let (mut chars, mut used) = (0usize, 0usize); - // `limit` comes from a (possibly hostile) request: every count is checked, never added - // blindly, so an enormous limit cannot overflow. - let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; - while let Some(para) = body.paragraphs.get(pi) { - for (i, ch) in committed_chars(para, comp_in(comp, pi)) { - if i < at { - continue; - } - let n = ch.len_utf8(); - if !fits(used, chars, n) { - return ((pi, i), chars, used); - } - used += n; - chars += 1; - at = i + 1; - } - if pi + 1 >= body.paragraphs.len() { - break; - } - // The paragraph separator between this paragraph and the next. - if !fits(used, chars, 1) { - return ((pi, at), chars, used); - } - used += 1; - chars += 1; - pi += 1; - at = 0; - } - ((pi, at), chars, used) -} - -/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole -/// characters). Returns the position reached and the characters and bytes consumed. -fn walk_backward( - body: &TextBody, - comp: Option<(usize, usize, usize)>, - from: (usize, usize), - limit: usize, - bytes: bool, -) -> ((usize, usize), usize, usize) { - let (mut pi, mut at) = from; - let (mut chars, mut used) = (0usize, 0usize); - let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; - loop { - let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) }; - let mut boundary = at.min(para.char_len()); - for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) { - if i >= boundary { - continue; - } - let n = ch.len_utf8(); - if !fits(used, chars, n) { - return ((pi, boundary), chars, used); - } - used += n; - chars += 1; - boundary = i; - } - // This paragraph is consumed down to its start. - if pi == 0 { - return ((0, 0), chars, used); - } - if !fits(used, chars, 1) { - return ((pi, 0), chars, used); - } - used += 1; - chars += 1; - pi -= 1; - at = usize::MAX; // all of the previous paragraph is before us - } -} - -/// The committed logical text between two model positions (excluding `end`). `None` when it is -/// longer than `cap` bytes: every caller only needs a bounded answer. -fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option { - let (a, b) = if start <= end { (start, end) } else { (end, start) }; - let mut out = String::new(); - for pi in a.0..=b.0 { - let Some(para) = body.paragraphs.get(pi) else { return Some(out) }; - let from = if pi == a.0 { a.1 } else { 0 }; - let to = if pi == b.0 { b.1 } else { usize::MAX }; - for (i, ch) in committed_chars(para, comp_in(comp, pi)) { - if i < from || i >= to { - continue; - } - out.push(ch); - if out.len() > cap { - return None; - } - } - if pi < b.0 { - out.push('\n'); - if out.len() > cap { - return None; - } - } - } - Some(out) -} - -/// Committed surrounding text for the text being edited. -/// -/// Returns `None` when the body or selection is invalid, or when the selection alone is wider -/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct -/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection. -/// -/// Only a bounded window is measured and copied: the query walks at most the budget out from the -/// selection on each side and does not copy the rest of the body. Finding the selection's -/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not -/// bounded independently of the paragraph length. -pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option { - let body = body_of(st, t)?; - let comp = composition_range(body, st.composition.as_ref()); - let caret = model_pos(body, t.caret)?; - let anchor = model_pos(body, t.anchor)?; - let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) }; - let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?; - let selection_len = selected.len(); - let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len); - let (_, _, before_available) = walk_backward(body, comp, start, budget, true); - let (_, _, after_available) = walk_forward(body, comp, end, budget, true); - let mut before = (budget / 2).min(before_available); - let mut after = (budget - before).min(after_available); - before = (budget - after).min(before_available); - after = (budget - before).min(after_available); - let (window_start, _, _) = walk_backward(body, comp, start, before, true); - let (window_end, _, _) = walk_forward(body, comp, end, after, true); - let before_text = logical_between(body, comp, window_start, start, budget)?; - let after_text = logical_between(body, comp, end, window_end, budget)?; - let before_len = before_text.len(); - let mut text = before_text; - text.push_str(&selected); - text.push_str(&after_text); - // The budget arithmetic above already bounds this; refuse rather than publish more. - if text.len() > IME_SURROUNDING_MAX_BYTES { - return None; - } - // The caret and anchor are the selection's own endpoints, so their offsets inside the - // window are the window's start plus either end of the selected text. - let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) }; - Some(ImeSurrounding { text, cursor, anchor }) -} - /// 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_POINT))?; @@ -399,1807 +186,3 @@ pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T } Ok(r) } - -/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout -/// computes (#724), then 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<()> { - fit_shrink_scale(s, id)?; - 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 = deckcraft_render::shape_geometry(&sh, x.w, x.h); - let fields = deckcraft_text::NoFields; - let mut nx = x; - if bp.wrap == Some(false) { - nx.w = deckcraft_text::fit_width(&ctx, &sh, &body, geo.text_rect, &fields).max(20.0); - } - let geo = deckcraft_render::shape_geometry(&sh, nx.w, nx.h); - let need = deckcraft_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) = deckcraft_model::find_shape_mut(list, id) - { - target.xfrm = Some(nx); - } - st.doc = Arc::new(d); - st.revision += 1; - Ok(()) -} - -/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps -/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text -/// change: a body with no meaningful saved scale — a file's bare `` (`None`) or -/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is -/// left as it is, as PowerPoint draws it. -fn fit_shrink_scale(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 Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) }; - if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) { - return Ok(()); - } - let doc = st.doc.clone(); - let sel = st.selection.clone(); - let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; - let x = xfrm_of(&doc, &sel, &sh); - let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect; - // Lay out as if the scale had just been reset, so the fit is computed even for `None` (which - // is drawn at 100 % and only fitted again when its text changes). - let mut probe = body.clone(); - probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); - let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }; - let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts); - if l.font_scale >= 1.0 && l.line_reduction <= 0.0 { - return Ok(()); - } - // Change the body in place without an extra undo step (it is part of the edit). - let st = s.doc_mut()?; - let mut d = (*st.doc).clone(); - let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection) - && let Some(target) = deckcraft_model::find_shape_mut(list, id) - && let Some(t) = target.text.as_mut() - && let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit - { - (*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction); - true - } else { - false - }; - if stored { - 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)), - t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), - Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), - } -} - -/// Lay out the text being edited (for vertical caret movement and hit testing). -pub fn layout_for(st: &DocState, t: &TextSel) -> Option { - text_frame(st, t).map(|f| f.0) -} - -/// The text being edited as it is drawn: its layout, and the transform from layout space to the -/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the -/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. -pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { - if t.notes { - return None; - } - let sh = st.shape(t.shape)?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let lay = |s: &Shape, body: &TextBody, rect: Rect| { - deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) - }; - if let Some((r, c)) = t.cell { - // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the - // table style's text look, exactly as the table draws it. - let placed = cell_on_slide(st, t.shape, r, c)?; - return Some((placed.layout, placed.to_slide)); - } - let body = sh.text.as_ref()?; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let l = lay(sh, body, tr); - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - Some((l, m)) -} - -/// The text position under slide point `p` in the text being edited (its shape or cell). -pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { - match text_frame(st, t) { - Some((l, m)) => { - let h = l.hit(m.inverse() * p); - (h.para, h.ch) - } - None => (0, 0), - } -} - -/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and -/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is -/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes, -/// which are an egui text field. -pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { - if t.notes { - return None; - } - if let Some((row, col)) = t.cell { - let placed = cell_on_slide(st, t.shape, row, col)?; - let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; - let (cx, top, bot) = placed.layout.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; - return Some((placed.to_slide * deckcraft_geom::Point::new(cx, top), placed.to_slide * deckcraft_geom::Point::new(cx, bot))); - } - let sh = st.shape(t.shape)?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let x = xfrm_of(&st.doc, &st.selection, sh); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - let mut body = sh.text.clone().unwrap_or_default(); - if body.paragraphs.is_empty() { - body.paragraphs.push(deckcraft_model::text::Paragraph::default()); - } - let l = deckcraft_text::layout( - &ctx, - sh, - &body, - &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); - // After End on a wrapped line the caret is drawn at that line's end. - let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; - let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; - let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); - Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) -} - -/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows -/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text -/// has no fill to take a colour from. -pub fn caret_color(st: &DocState, t: &TextSel) -> Option { - if t.notes || t.cell.is_some() { - return None; - } - let sh = st.shape(t.shape)?; - let body = sh.text.as_ref()?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let blank = Paragraph::default(); - let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank); - let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret)); - Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0) -} - -fn pos_param(p: &Value, key: &str) -> Option { - let a = p.get(key)?.as_array()?; - Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)) -} - -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(); - sel.cells = None; - })?; - return ok(); - } - let id = match id_param(p, "id", "text.edit")? { - Some(i) => i, - None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, - }; - 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) = deckcraft_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, line_end: None }); - sel.cells = None; - })?; - 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 inherited = inherited_aligns_for_edit(s); - 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 { unlinked_at_link_end(body, at) }); - let c = ed::insert(body, at, &text, props); - t.anchor = c; - t.caret = c; - let last = body.paragraphs.len().saturating_sub(1); - for pi in at.0..=c.0.min(last) { - if let Some(para) = body.paragraphs.get_mut(pi) { - promote_rtl_if_needed(para, &inherited); - } - } - c - })?; - autocorrect(s, text.chars().count() == 1)?; - // AutoCorrect can move the caret: a replaced word, or a list lead it removed. - let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret); - Ok(json!({"caret": [pos.0, pos.1]})) -} - -/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an -/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false` -/// when the shape can't be resolved (notes, a shape that is gone). -fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] { - let mut out = [false; 9]; - let Ok(st) = s.doc() else { return out }; - let Some(sh) = st.shape(id) else { return out }; - let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out }; - for (lvl, set) in out.iter_mut().enumerate() { - let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() }; - *set = resolve::para(&ctx, sh, ¶).align.is_some(); - } - out -} - -/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes). -fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] { - match s.doc().ok().and_then(|st| st.selection.text.clone()) { - Some(t) if !t.notes => inherited_aligns(s, t.shape), - _ => [false; 9], - } -} - -/// A paragraph with no direction of its own whose first strong character is right-to-left -/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the -/// paragraph or what it inherits, `inherited`) sets its alignment. -fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) { - if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { - para.props.rtl = Some(true); - let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false); - if para.props.align.is_none() && !inherits_align { - para.props.align = Some(Align::Right); - } - } -} - -/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked -/// run, the props to type with are the run's without the link. `None` elsewhere. -fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { - let mut props = ed::props_at(body, at); - props.link.as_ref()?; - let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count()); - // props_at(c + 1) is the props of the character at c. - if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link { - return None; - } - props.link = None; - Some(props) -} - -/// Built-in AutoCorrect replacements (common slips and symbols). -pub const AUTOCORRECT: &[(&str, &str)] = &[ - ("teh", "the"), - ("adn", "and"), - ("recieve", "receive"), - ("recieved", "received"), - ("seperate", "separate"), - ("occured", "occurred"), - ("occurence", "occurrence"), - ("thier", "their"), - ("wich", "which"), - ("acheive", "achieve"), - ("accross", "across"), - ("adress", "address"), - ("alot", "a lot"), - ("appearence", "appearance"), - ("arguement", "argument"), - ("basicly", "basically"), - ("beggining", "beginning"), - ("beleive", "believe"), - ("calender", "calendar"), - ("commitee", "committee"), - ("definately", "definitely"), - ("dont", "don't"), - ("doesnt", "doesn't"), - ("didnt", "didn't"), - ("isnt", "isn't"), - ("embarass", "embarrass"), - ("enviroment", "environment"), - ("existance", "existence"), - ("foward", "forward"), - ("freind", "friend"), - ("goverment", "government"), - ("happend", "happened"), - ("i", "I"), - ("ive", "I've"), - ("immediatly", "immediately"), - ("independant", "independent"), - ("knowlege", "knowledge"), - ("libary", "library"), - ("neccessary", "necessary"), - ("necesary", "necessary"), - ("noticable", "noticeable"), - ("occassion", "occasion"), - ("posession", "possession"), - ("prefered", "preferred"), - ("publically", "publicly"), - ("realy", "really"), - ("recomend", "recommend"), - ("refered", "referred"), - ("relevent", "relevant"), - ("succesful", "successful"), - ("tommorow", "tomorrow"), - ("tomorow", "tomorrow"), - ("truely", "truly"), - ("untill", "until"), - ("wierd", "weird"), - ("youre", "you're"), - ("(c)", "©"), - ("(r)", "®"), - ("(tm)", "™"), - ("(e)", "€"), - ("...", "…"), - ("->", "→"), - ("<-", "←"), - ("=>", "⇒"), - ("--", "–"), -]; - -const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; - -/// The replacement for `word` from your entries, then the built-in list. -fn replacement(prefs: &crate::Prefs, word: &str) -> Option { - if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) { - return Some(to.clone()); - } - if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) { - return Some(to.clone()); - } - let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?; - if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) { - return None; - } - // "i" only on its own, lower case. - if *k == "i" && word != "i" { - return None; - } - Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() }) -} - -/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces -/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was -/// typed (the case rules apply only then). -pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option { - let mut out = replacement(prefs, word); - if typing && out.is_none() { - let chars: Vec = word.chars().collect(); - let letters = chars.iter().all(|c| c.is_alphabetic()); - if prefs.ac_fractions { - out = match word { - "1/2" => Some("½".into()), - "1/4" => Some("¼".into()), - "3/4" => Some("¾".into()), - _ => None, - }; - } - if out.is_none() && letters && chars.len() >= 3 { - let upper = |c: &char| c.is_uppercase(); - let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase())); - let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper)); - if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower { - out = - Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::() } else { c.to_string() }).collect()); - } else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper { - out = Some( - chars - .iter() - .enumerate() - .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect() }) - .collect(), - ); - } - } - if out.is_none() && prefs.ac_days && DAYS.contains(&word) { - out = Some(ed::change_case(word, "sentence")); - } - } - if typing && prefs.ac_sentence_caps && sentence_start { - let w = out.clone().unwrap_or_else(|| word.to_string()); - let mut cs = w.chars(); - if let Some(f) = cs.next() - && f.is_lowercase() - && w.chars().all(char::is_alphabetic) - { - out = Some(f.to_uppercase().chain(cs).collect()); - } - } - out.filter(|o| o != word) -} - -/// After a space or punctuation, correct the word just typed. -fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { - if !s.prefs.autocorrect { - return Ok(()); - } - 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(()); - } - if typing && s.prefs.ac_lists { - // A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list. - let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); - if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? { - return Ok(()); - } - } - let mut word_end = end - 1; - let mut start = word_end; - while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { - start -= 1; - } - // "word," and "word." also count: the punctuation was typed before the space. - let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); - if replacement(&s.prefs, &word).is_none() { - let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c)); - if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") { - word_end -= word.chars().count() - trimmed.chars().count(); - word = trimmed.to_string(); - } - } - let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None); - let sentence_start = before.is_none_or(|c| ".!?".contains(c)); - // "word..." becomes "word…", the word corrected too. - let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some(); - let rep = if ellipsis { - let stem = word.trim_end_matches('.'); - Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string()))) - } else { - correct_word(&s.prefs, &word, sentence_start, typing) - }; - let Some(rep) = rep else { - if typing && s.prefs.ac_ordinals && is_ordinal(&word) { - return superscript_suffix(s, &t, word_end); - } - return Ok(()); - }; - let delta = rep.chars().count() as isize - word.chars().count() as isize; - let st = s.doc_mut()?; - let mut d = (*st.doc).clone(); - 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(()) -} - -/// A list AutoFormat started: the text being edited right after it, the typed lead with its -/// space, the paragraph's own formatting before, and the revision the change left. -#[derive(Clone, Debug)] -pub struct AutoList { - sel: TextSel, - typed: String, - props: deckcraft_model::text::ParaProps, - revision: u64, -} - -/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and -/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering -/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`. -fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> { - let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c}))); - let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme}))); - match lead { - "*" => bullet("•"), - "-" | "–" => bullet("–"), - "--" | "—" => bullet("■"), - ">" => bullet("➢"), - "<>" => bullet("◆"), - "1." => number("arabicPeriod"), - "1)" => number("arabicParenR"), - "I." => number("romanUcPeriod"), - "i." => number("romanLcPeriod"), - "A." => number("alphaUcPeriod"), - "a." => number("alphaLcPeriod"), - "a)" => number("alphaLcParenR"), - _ => None, - } -} - -/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a -/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed -/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed. -fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result { - if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') { - return Ok(false); - } - let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default(); - let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) }; - let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); - let para = t.caret.0; - let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default(); - edit_text(s, |body, tt| { - ed::delete(body, (para, 0), (para, end)); - tt.anchor = (para, 0); - tt.caret = (para, 0); - })?; - s.execute(id, ¶ms)?; - let st = s.doc_mut()?; - if let Some(sel) = st.selection.text.clone() { - st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision }); - } - Ok(true) -} - -/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and -/// space and the formatting it had. `false` when the last change was anything else. -fn undo_auto_list(s: &mut Session) -> Result { - let st = s.doc_mut()?; - let Some(a) = st.auto_list.take() else { return Ok(false) }; - if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) { - return Ok(false); - } - edit_text(s, |body, t| { - if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) { - p.props = a.props.clone(); - } - let c = ed::insert(body, a.sel.caret, &a.typed, None); - t.anchor = c; - t.caret = c; - })?; - Ok(true) -} - -/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat -/// superscripts it. -fn is_ordinal(word: &str) -> bool { - let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase()); - let suffix = word.get(digits.len()..).unwrap_or_default(); - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 { - return false; - } - let d = digits.as_bytes(); - let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1'); - let want = match d.last() { - _ if teen => "th", - Some(b'1') => "st", - Some(b'2') => "nd", - Some(b'3') => "rd", - _ => "th", - }; - suffix == want -} - -/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix). -fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> { - let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end)); - let st = s.doc_mut()?; - if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) { - return Ok(()); - } - let mut d = (*st.doc).clone(); - let sel = st.selection.clone(); - if let Some(body) = body_mut(&mut d, &sel, t) { - ed::format(body, a, b, &|r| r.baseline = Some(0.3)); - } - st.doc = Arc::new(d); - st.revision += 1; - Ok(()) -} - -fn autocorrect_options(s: &mut Session, p: &Value) -> Result { - let pr = &mut s.prefs; - for (key, slot) in [ - ("on", &mut pr.autocorrect), - ("twoInitials", &mut pr.ac_two_initials), - ("sentenceCaps", &mut pr.ac_sentence_caps), - ("days", &mut pr.ac_days), - ("capsLock", &mut pr.ac_caps_lock), - ("fractions", &mut pr.ac_fractions), - ("ordinals", &mut pr.ac_ordinals), - ("lists", &mut pr.ac_lists), - ] { - if let Some(v) = p.get(key).and_then(Value::as_bool) { - *slot = v; - } - } - if let Some(Value::Array(a)) = p.get("add") { - let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else { - return Err(bad("text.autocorrect", "`add` is [from, to]")); - }; - let from = from.trim(); - if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 { - return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255")); - } - pr.ac_entries.retain(|(k, _)| k != from); - pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from)); - if pr.ac_entries.len() >= 2000 { - return Err(bad("text.autocorrect", "the list is full")); - } - pr.ac_entries.push((from.to_string(), to.to_string())); - } - if let Some(from) = str_param(p, "remove") { - let had = pr.ac_entries.len(); - pr.ac_entries.retain(|(k, _)| k != from); - if had == pr.ac_entries.len() - && AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from)) - && !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from)) - { - pr.ac_removed.push(from.to_string()); - } - } - let list: Vec<(String, String)> = pr - .ac_entries - .iter() - .cloned() - .chain( - AUTOCORRECT - .iter() - .filter(|(k, _)| { - !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k)) - }) - .map(|(k, v)| (k.to_string(), v.to_string())), - ) - .collect(); - Ok(json!({ - "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, - "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, - "ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list, - })) -} - -pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { - let inherited = inherited_aligns_for_edit(s); - let pos = edit_text(s, |body, t| { - let (a, b) = t.ordered(); - let at = ed::delete(body, a, b); - let c = ed::insert_body(body, at, src); - t.anchor = c; - t.caret = c; - let last = body.paragraphs.len().saturating_sub(1); - for pi in at.0..=c.0.min(last) { - if let Some(para) = body.paragraphs.get_mut(pi) { - promote_rtl_if_needed(para, &inherited); - } - } - 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![deckcraft_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() -} - -/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own). -fn ime_text(s: &str) -> String { - s.chars().filter(|c| !c.is_control()).collect() -} - -/// Is the composition still where it was put (the paragraph holds all of it)? -fn composition_fits(st: &DocState, c: &crate::Composition) -> bool { - let Some(t) = &st.selection.text else { return false }; - let Some(body) = body_of(st, t) else { return false }; - body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len()) -} - -/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection) -/// or replace the composition with the new preedit. An empty preedit cancels: the presentation -/// goes back to how it was before the composition began. -fn compose(s: &mut Session, p: &Value) -> Result { - let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?); - let n = text.chars().count(); - let active = p.get("active").and_then(Value::as_array).and_then(|a| { - let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); - (x < y).then_some((x.min(n), y.min(n))) - }); - let current = s.doc()?.composition.clone(); - // A composition whose text was changed under it is kept as it is; a new one starts. - let current = match current { - Some(c) if !composition_fits(s.doc()?, &c) => { - // The text changed under the pre-edit: the app settled it, so the platform's copy - // must be cancelled too. - s.settle_composition(); - None - } - c => c, - }; - match current { - None if text.is_empty() => Ok(json!({"composing": false})), - None => { - let st = s.doc()?; - let (before, before_selection) = (st.doc.clone(), st.selection.clone()); - let (start, props) = edit_text(s, |body, t| { - let (a, b) = t.ordered(); - let at = ed::delete(body, a, b); - let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }; - let c = ed::insert(body, at, &text, props.clone()); - t.anchor = c; - t.caret = c; - (at, props) - })?; - s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection }); - Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) - } - Some(c) if text.is_empty() => { - let st = s.doc_mut()?; - st.doc = c.before; - st.selection = c.before_selection; - st.composition = None; - st.revision += 1; - Ok(json!({"composing": false})) - } - Some(c) => { - let (start, len, props) = (c.start, c.len, c.props.clone()); - edit_text(s, |body, t| { - ed::delete(body, start, (start.0, start.1 + len)); - let e = ed::insert(body, start, &text, props); - t.anchor = e; - t.caret = e; - })?; - s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c }); - Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) - } - } -} - -/// `text.commit`: the input method's result replaces the composition (or is typed at the -/// insertion point when there is none) and the whole composition becomes one undo step. -fn commit(s: &mut Session, p: &Value) -> Result { - let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?); - if s.doc()?.composition.is_some() || !text.is_empty() { - compose(s, &json!({"text": text}))?; - } - s.end_composition(); - let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default(); - Ok(json!({"caret": [caret.0, caret.1]})) -} - -/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s -/// text as laid out on the current slide: where spelling squiggles go. -pub fn range_underlines( - st: &DocState, - id: deckcraft_model::ShapeId, - ranges: &[(usize, usize, usize)], -) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { - let Some(sh) = st.shape(id) else { return vec![] }; - let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - let mut out = vec![]; - for &(para, a, b) in ranges { - for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { - out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); - } - } - out -} - -/// Like [`range_underlines`], for the text of table `id`'s cell at `row`, `col`. -pub fn cell_range_underlines( - st: &DocState, - id: deckcraft_model::ShapeId, - row: usize, - col: usize, - ranges: &[(usize, usize, usize)], -) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { - let Some(sh) = st.shape(id) else { return vec![] }; - let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { return vec![] }; - let (Some(cell), Some(ctx)) = (tb.cell(row, col), ctx_for(&st.doc, &st.selection)) else { return vec![] }; - let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); - let Some(rect) = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col).map(|(_, _, r)| r) else { - return vec![]; - }; - let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); - let tmp = deckcraft_model::Shape::table_cell(body.clone()); - let l = deckcraft_text::layout( - &ctx, - &tmp, - &body, - &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); - let x = xfrm_of(&st.doc, &st.selection, sh); - let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation); - let mut out = vec![]; - for &(para, a, b) in ranges { - for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { - out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); - } - } - out -} - -/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, -/// thick)` per line, thick under the part being converted. Empty without a composition, or for -/// notes (an egui text field draws its own composition). -pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { - let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; - if t.notes { - return vec![]; - } - if let Some((row, col)) = t.cell { - let Some(placed) = cell_on_slide(st, t.shape, row, col) else { return vec![] }; - return underline_segments(&placed.layout, placed.to_slide, c); - } - let Some(sh) = st.shape(t.shape) else { return vec![] }; - let Some(l) = layout_for(st, t) else { return vec![] }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; - let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); - underline_segments(&l, m, c) -} - -fn underline_segments( - layout: &deckcraft_text::TextLayout, - to_slide: deckcraft_geom::Affine, - c: &crate::Composition, -) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { - let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; - let mut out = vec![]; - let mut add = |a: usize, b: usize, thick: bool| { - for (x0, x1, y) in layout.composition_underlines(pos(a), pos(b)) { - out.push((to_slide * deckcraft_geom::Point::new(x0, y), to_slide * deckcraft_geom::Point::new(x1, y), thick)); - } - }; - add(0, c.len, false); - if let Some((a, b)) = c.active { - add(a, b.min(c.len), true); - } - out -} - -/// A table cell's text laid out the way it is drawn, mapped into slide coordinates. -fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option { - let sh = st.shape(id)?; - let ShapeKind::Table(tb) = &sh.kind else { return None }; - let cell = tb.cell(row, col)?; - let ctx = ctx_for(&st.doc, &st.selection)?; - let heights = super::table_drawn_heights(&st.doc, &st.selection, tb); - let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2; - let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); - if body.paragraphs.is_empty() { - body.paragraphs.push(deckcraft_model::text::Paragraph::default()); - } - let tmp = deckcraft_model::Shape::table_cell(body.clone()); - let layout = deckcraft_text::layout( - &ctx, - &tmp, - &body, - &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, - ); - let x = xfrm_of(&st.doc, &st.selection, sh); - let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation); - Some(CellText { layout, to_slide }) -} - -struct CellText { - layout: deckcraft_text::TextLayout, - to_slide: deckcraft_geom::Affine, -} - -fn insert(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").ok_or_else(|| bad("text.insert", "missing `text`"))?.to_string(); - insert_text(s, &text, None) -} - -pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { - if dir == "backward" && undo_auto_list(s)? { - return ok(); - } - let dir = dir.to_string(); - edit_text(s, move |body, t| { - let (a, b) = t.ordered(); - let at = if dir == "wordBackward" || dir == "wordForward" { - // Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it - // (PowerPoint for Windows). - ed::delete_word(body, a, dir == "wordForward") - } else if a != b { - ed::delete(body, a, b) - } else { - let other = match dir.as_str() { - "forward" => ed::char_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() -} - -/// A `before`/`after` count from a control or input-method request. -/// -/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is -/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that -/// would delete different text than asked. -fn count_param(p: &Value, name: &str) -> usize { - p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)) -} - -/// Delete at most the requested character or byte counts around the selection or pre-edit, -/// preserving selection direction and composition boundaries and recording one undo step -/// outside composition. -fn delete_surrounding(s: &mut Session, p: &Value) -> Result { - let before = count_param(p, "before"); - let after = count_param(p, "after"); - let bytes = bool_or(p, "bytes", false); - let st = s.doc()?; - let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?; - let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?; - let comp = composition_range(body, st.composition.as_ref()); - // The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a - // paragraph separator and for a soft line break. A range may therefore cross paragraphs. - let ordered = { - let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; - let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; - if a <= b { (a, b) } else { (b, a) } - }; - // The selection (or the input method's pre-edit) stays exactly where it is. Only whole - // scalars fully inside the requested before/after ranges around it are removed. - let (start, end) = match comp { - Some((pi, cs, ce)) => ((pi, cs), (pi, ce)), - None => ordered, - }; - let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes); - let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes); - let deleted = before_chars + after_chars; - if deleted == 0 { - return Ok(json!({"deleted": 0})); - } - let before_doc = st.doc.clone(); - let before_sel = st.selection.clone(); - let composing = st.composition.is_some(); - let vertex_kind = s.tool.vertex_kind.clone(); - edit_text(s, |body, t| { - // Delete the after-range first, so the positions of the earlier range stay valid; then - // the text before the selection. - if after_chars > 0 { - ed::delete(body, end, to); - } - if before_chars > 0 { - ed::delete(body, from, start); - } - t.anchor = remap_after_delete(t.anchor, start, from, before_chars); - t.caret = remap_after_delete(t.caret, start, from, before_chars); - t.line_end = None; - })?; - if composing { - if let Some(c) = s.doc_mut()?.composition.as_mut() { - c.start = if before_chars == 0 { - start - } else if from.0 == start.0 { - (start.0, start.1 - before_chars) - } else { - from - }; - } - } else { - push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind }); - } - Ok(json!({"deleted": deleted})) -} - -/// Map a text position across the deletion of `before_chars` characters that begins at `start` -/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier -/// paragraph those paragraphs merge, so later paragraph numbers shift too. -fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) { - if before_chars == 0 || pos < start { - return pos; - } - if from.0 == start.0 { - // Only endpoints in the start paragraph move; later paragraphs keep their text. - if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos } - } else { - (from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 }) - } -} - -fn delete(s: &mut Session, p: &Value) -> Result { - delete_dir(s, str_param(p, "dir").unwrap_or("backward")) -} - -/// Is paragraph `para` right-to-left (its lines run from the right)? -fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool { - match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) { - Some(line) => line.rtl, - None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false), - } -} - -fn move_caret(s: &mut Session, p: &Value) -> Result { - use deckcraft_text::Affinity; - 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| deckcraft_text::Pos { para: c.0, ch: c.1 }; - let caret = t.caret; - let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream }; - // Left and right are on screen: in a right-to-left paragraph left goes forward in the text. - let rtl = para_rtl(layout.as_ref(), &body, caret.0); - let to = match (to.as_str(), rtl) { - ("left", true) => "right", - ("right", true) => "left", - ("wordLeft", true) => "wordRight", - ("wordRight", true) => "wordLeft", - (x, _) => x, - } - .to_string(); - let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 }; - let mut upstream = false; - 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), - // Ctrl+arrows with a selection go on from its start (left) or end (right). - "wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false), - "wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true), - "paraStart" => (caret.0, 0), - "paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), - "paraUp" => ed::para_up(&body, caret), - "paraDown" => ed::para_down(&body, caret), - "start" => (0, 0), - "end" => ed::end(&body), - "up" | "down" | "lineStart" | "lineEnd" => match &layout { - Some(l) => { - let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0); - let (r, a) = match to.as_str() { - "up" => l.vertical_affine(lp(caret), aff, false, x), - "down" => l.vertical_affine(lp(caret), aff, true, x), - "lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream), - // End stays on its line: after a soft wrap the caret sits at the line's end. - _ => l.line_end_caret(lp(caret), aff), - }; - upstream = a == Affinity::Upstream; - (r.para, r.ch) - } - None => match to.as_str() { - "up" => (caret.0.saturating_sub(1), caret.1), - "down" => (caret.0.saturating_add(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; - tt.line_end = if upstream { Some(new) } else { None }; - if !extend { - tt.anchor = new; - } - } - })?; - Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream})) -} - -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 body = body_of(st, &t); - // Clamp to the last paragraph so a huge index can neither overflow nor select past the body. - let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1); - let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); - // The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too). - let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) }; - s.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.anchor = (at.0, 0); - t.caret = end; - } - })?; - ok() -} - -/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them. -/// Returns whether any tab was found. -pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool { - let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); - if tabs == 0 { - return false; - } - if let Some(r) = para.runs.first_mut() { - r.text = r.text.trim_start_matches('\t').to_string(); - } - para.level = tabs.min(8) as u8; - true -} - -fn set(s: &mut Session, p: &Value) -> Result { - let text = str_param(p, "text").ok_or_else(|| bad("text.set", "missing `text`"))?.to_string(); - let id = match id_param(p, "id", "text.set")? { - Some(i) => i, - None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, - }; - let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); - let inherited = inherited_aligns(s, id); - edit_shapes(s, &json!({"id": id}), "text.set", |sh| { - let target = match (&mut sh.kind, cell) { - (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), - (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, deckcraft_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. - if !apply_leading_tabs(para) - && let Some((lv, pp)) = levels.get(i) - { - para.level = *lv; - para.props = pp.clone(); - } - promote_rtl_if_needed(para, &inherited); - for r in &mut para.runs { - r.props = keep.clone(); - } - } - 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", "text.get")? { - 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 (a, b) = format_range(st, &t); - 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_SHAPE)); - } - let cells = st.selection.cells; - edit_shapes(s, &json!({"ids": ids}), "format", |sh| { - let id = sh.id; - match &mut sh.kind { - ShapeKind::Table(t) => { - // Only the selected cells, when a range of them is selected. - let range = super::table::selected_range(cells, id, t); - for (r, row) in t.rows.iter_mut().enumerate() { - for (c, cell) in row.cells.iter_mut().enumerate() { - if super::table::in_range(range, r, c) { - ed::format_all(&mut cell.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() -} - -/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in -/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink -/// edits this same span, so the dialog reads it too. -pub fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { - let body = body_of(st, t).cloned().unwrap_or_default(); - let (mut a, mut b) = t.ordered(); - if a == b { - 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; - } - } - } - (a, b) -} - -/// Link the text being edited: the selection or the word at the caret, as `format_run` would. -/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the -/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does. -pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result { - let st = s.doc()?; - let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) }; - let (a, b) = format_range(st, &t); - if a != b { - return format_run(s, &move |rp| rp.link = Some(link.clone())); - } - if display.is_empty() { - return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`")); - } - let pos = edit_text(s, |body, t| { - let mut props = ed::props_at(body, a); - props.link = Some(link); - let c = ed::insert(body, a, display, Some(props)); - t.anchor = c; - t.caret = c; - c - })?; - Ok(json!({"caret": [pos.0, pos.1]})) -} - -/// 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 deckcraft_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()); - } - let last = body.paragraphs.len().saturating_sub(1); - for pi in a.0..=b.0.min(last) { - 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_SHAPE)); - } - let cells = st.selection.cells; - edit_shapes(s, &json!({"ids": ids}), "format", |sh| { - let id = sh.id; - match &mut sh.kind { - ShapeKind::Table(t) => { - let range = super::table::selected_range(cells, id, t); - for (r, row) in t.rows.iter_mut().enumerate() { - for (c, cell) in row.cells.iter_mut().enumerate() { - if super::table::in_range(range, r, c) { - cell.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 deckcraft_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() -} - -fn fonts_used(s: &mut Session, _: &Value) -> Result { - let mut v = crate::fonts::used_fonts(&s.doc()?.doc); - v.sort_by_key(|f| f.to_lowercase()); - Ok(json!(v)) -} - -fn replace_font(s: &mut Session, p: &Value) -> Result { - let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string(); - let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string(); - if to.len() > 256 || to.chars().any(char::is_control) { - return Err(bad("font.replace", "`to` is not a font name")); - } - // Count on a copy first so a replace that finds nothing leaves no undo step. - let mut probe = (*s.doc()?.doc).clone(); - if crate::fonts::replace_font(&mut probe, &from, &to) == 0 { - return Ok(json!({"replaced": 0})); - } - let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?; - let st = s.doc_mut()?; - st.missing_fonts = crate::fonts::missing_fonts(&st.doc); - Ok(json!({"replaced": n})) -} - -#[cfg(test)] -mod rtl_promote_tests { - use deckcraft_model::text::{Align, Paragraph, Run}; - - use super::promote_rtl_if_needed; - - fn para(text: &str) -> Paragraph { - Paragraph { runs: vec![Run::new(text)], ..Default::default() } - } - - #[test] - fn right_aligns_only_when_no_alignment_is_set_or_inherited() { - let mut p = para("مرحبا"); - promote_rtl_if_needed(&mut p, &[false; 9]); - assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right))); - let mut p = para("مرحبا"); - promote_rtl_if_needed(&mut p, &[true; 9]); - assert_eq!((p.props.rtl, p.props.align), (Some(true), None)); - let mut p = para("Hello مرحبا"); - promote_rtl_if_needed(&mut p, &[false; 9]); - assert_eq!((p.props.rtl, p.props.align), (None, None)); - } -} - -#[cfg(test)] -mod ime_surrounding_tests { - #![allow(clippy::unwrap_used, clippy::expect_used)] - - use serde_json::json; - - use crate::Session; - - /// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id. - fn edit(s: &mut Session) -> u64 { - let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap(); - let id = r["id"].as_u64().unwrap(); - s.execute("text.edit", &json!({"id": id})).unwrap(); - id - } - - /// Test helper: return the identified shape's text, or an empty string when it is gone. - fn text(s: &Session, id: u64) -> String { - let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap()); - s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default() - } - - /// Test helper: set an in-paragraph text selection through the engine command path. - fn select(s: &mut Session, anchor: usize, caret: usize) { - s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap(); - } - - /// DeleteSurrounding removes only the requested text around the selection. The selection - /// itself and its direction stay, so the following commit replaces it. - #[test] - fn selected_text_survives_the_requested_characters_or_bytes_around_it() { - let mut s = Session::with_new(); - let id = edit(&mut s); - select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀 - s.delete_surrounding(1, 1, false).unwrap(); - assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays"); - let t = s.doc().unwrap().selection.text.clone().unwrap(); - assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - - select(&mut s, 1, 3); - s.delete_surrounding(1, 1, true).unwrap(); - assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - } - - /// A byte range removes only whole scalars fully contained in it. A count that ends inside - /// a character leaves that character whole (and undeleted), never splitting it. - #[test] - fn byte_counts_round_inward_to_whole_characters() { - let mut s = Session::with_new(); - let id = edit(&mut s); - select(&mut s, 2, 2); // after é: bytes 0..3 - s.delete_surrounding(1, 0, true).unwrap(); - assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar"); - s.delete_surrounding(2, 0, true).unwrap(); - assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - - select(&mut s, 2, 2); - s.delete_surrounding(0, 1, true).unwrap(); - assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar"); - s.delete_surrounding(0, 4, true).unwrap(); - assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji"); - } - - /// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed - /// selection, removes the model-stored pre-edit, and treats an empty body as available. - #[test] - fn surrounding_text_is_committed_logical_and_bounded() { - use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding}; - - let mut s = Session::with_new(); - let id = edit(&mut s); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8)); - - s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor"); - assert_eq!(q.text, "aé😀b"); - - s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); - s.execute("text.move", &json!({"to": "end"})).unwrap(); - s.compose_text("日本", None).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text"); - - s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available"); - - let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500); - s.execute("text.set", &json!({"id": id, "text": long})).unwrap(); - s.execute("text.move", &json!({"to": "end"})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.clone().unwrap(); - let q = ime_surrounding(st, &t).unwrap(); - assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len()); - assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window"); - } - - /// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may - /// remove one and merge the paragraphs, exactly as the published offsets say. - #[test] - fn paragraph_marks_are_deletable_logical_text() { - use super::ime_surrounding; - - let mut s = Session::with_new(); - let id = edit(&mut s); - s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap(); - // The caret at the start of the second paragraph: after "ab" and its paragraph mark. - s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); - let st = s.doc().unwrap(); - let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3)); - - // One byte before the caret is that paragraph mark. - s.delete_surrounding(1, 0, true).unwrap(); - assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge"); - let t = s.doc().unwrap().selection.text.clone().unwrap(); - assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "ab\ncd"); - - // Characters count the same mark. - s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); - s.delete_surrounding(1, 0, false).unwrap(); - assert_eq!(text(&s, id), "abcd"); - s.execute("edit.undo", &json!({})).unwrap(); - - // And from the other side: one character after the end of the first paragraph. - s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); - s.delete_surrounding(0, 1, false).unwrap(); - assert_eq!(text(&s, id), "abcd"); - } - - /// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the - /// platform must never see the model's `\u{b}`, which its own text filter would reject. - #[test] - fn soft_line_breaks_read_as_newlines() { - use super::ime_surrounding; - - let mut s = Session::with_new(); - let id = edit(&mut s); - s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates - - s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); - s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); - s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap(); - assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph"); - let st = s.doc().unwrap(); - let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); - assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2)); - - // The break is one logical byte, so removing it removes one character. - s.delete_surrounding(1, 0, true).unwrap(); - assert_eq!(text(&s, id), "ab"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "a\u{b}b"); - } - - /// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a - /// different number that would delete other text than the platform asked for. - #[test] - fn out_of_range_counts_clamp_instead_of_truncating() { - let mut s = Session::with_new(); - let id = edit(&mut s); - select(&mut s, 1, 1); - s.delete_surrounding(usize::MAX, 0, false).unwrap(); - assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic"); - let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX); - assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); - assert!(expected > 0, "the count is clamped, not wrapped to nothing"); - assert_eq!(super::count_param(&json!({}), "before"), 0); - } - - /// A non-composing input-method deletion is exactly one undo step; the manual history entry - /// must not be duplicated by the generic command wrapper. - #[test] - fn a_non_composing_ime_deletion_is_one_undo_step() { - let mut s = Session::with_new(); - let id = edit(&mut s); - let undo = s.doc().unwrap().history.undo.len(); - select(&mut s, 3, 3); - s.delete_surrounding(1, 0, false).unwrap(); - assert_eq!(text(&s, id), "aéb"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(text(&s, id), "aé😀b"); - } -} - -#[cfg(test)] -mod replace_font_tests { - use serde_json::json; - - use crate::Session; - - fn fonts(s: &mut Session) -> Vec { - serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap() - } - - #[test] - fn replaces_runs_tables_notes_and_theme_and_undoes() { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); - let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone(); - s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap(); - s.execute("text.exit", &json!({})).unwrap(); - s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); - assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s)); - let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap(); - assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}"); - let used = fonts(&mut s); - assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}"); - // The theme's body font is replaced too, so placeholders follow. - s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato"); - assert!(s.doc().unwrap().doc.validate().is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue")); - } - - #[test] - fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() { - let mut s = Session::with_new(); - let undo = s.doc().unwrap().history.undo.len(); - assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0); - assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err()); - assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err()); - assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err()); - } -} - -#[cfg(test)] -mod select_paragraph_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn select_paragraph_huge_at() { - let mut s = Session::with_new(); - s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); - s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap(); - s.execute("text.edit", &json!({})).unwrap(); - let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})); - assert!(r.is_ok(), "{r:?}"); - let t = s.doc().unwrap().selection.text.clone().unwrap(); - // Clamped to the last (only) paragraph, never past the end of the body. - assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5))); - } -} diff --git a/crates/engine/src/cmd/text/autocorrect.rs b/crates/engine/src/cmd/text/autocorrect.rs new file mode 100644 index 000000000..d1cb9dcda --- /dev/null +++ b/crates/engine/src/cmd/text/autocorrect.rs @@ -0,0 +1,384 @@ +use super::{Arc, Result, Session, TextSel, Value, bad, body_mut, body_of, ed, edit_text, json, str_param}; + +/// Built-in AutoCorrect replacements (common slips and symbols). +pub const AUTOCORRECT: &[(&str, &str)] = &[ + ("teh", "the"), + ("adn", "and"), + ("recieve", "receive"), + ("recieved", "received"), + ("seperate", "separate"), + ("occured", "occurred"), + ("occurence", "occurrence"), + ("thier", "their"), + ("wich", "which"), + ("acheive", "achieve"), + ("accross", "across"), + ("adress", "address"), + ("alot", "a lot"), + ("appearence", "appearance"), + ("arguement", "argument"), + ("basicly", "basically"), + ("beggining", "beginning"), + ("beleive", "believe"), + ("calender", "calendar"), + ("commitee", "committee"), + ("definately", "definitely"), + ("dont", "don't"), + ("doesnt", "doesn't"), + ("didnt", "didn't"), + ("isnt", "isn't"), + ("embarass", "embarrass"), + ("enviroment", "environment"), + ("existance", "existence"), + ("foward", "forward"), + ("freind", "friend"), + ("goverment", "government"), + ("happend", "happened"), + ("i", "I"), + ("ive", "I've"), + ("immediatly", "immediately"), + ("independant", "independent"), + ("knowlege", "knowledge"), + ("libary", "library"), + ("neccessary", "necessary"), + ("necesary", "necessary"), + ("noticable", "noticeable"), + ("occassion", "occasion"), + ("posession", "possession"), + ("prefered", "preferred"), + ("publically", "publicly"), + ("realy", "really"), + ("recomend", "recommend"), + ("refered", "referred"), + ("relevent", "relevant"), + ("succesful", "successful"), + ("tommorow", "tomorrow"), + ("tomorow", "tomorrow"), + ("truely", "truly"), + ("untill", "until"), + ("wierd", "weird"), + ("youre", "you're"), + ("(c)", "©"), + ("(r)", "®"), + ("(tm)", "™"), + ("(e)", "€"), + ("...", "…"), + ("->", "→"), + ("<-", "←"), + ("=>", "⇒"), + ("--", "–"), +]; + +const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + +/// The replacement for `word` from your entries, then the built-in list. +fn replacement(prefs: &crate::Prefs, word: &str) -> Option { + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) { + return Some(to.clone()); + } + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) { + return Some(to.clone()); + } + let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?; + if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) { + return None; + } + // "i" only on its own, lower case. + if *k == "i" && word != "i" { + return None; + } + Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() }) +} + +/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces +/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was +/// typed (the case rules apply only then). +pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option { + let mut out = replacement(prefs, word); + if typing && out.is_none() { + let chars: Vec = word.chars().collect(); + let letters = chars.iter().all(|c| c.is_alphabetic()); + if prefs.ac_fractions { + out = match word { + "1/2" => Some("½".into()), + "1/4" => Some("¼".into()), + "3/4" => Some("¾".into()), + _ => None, + }; + } + if out.is_none() && letters && chars.len() >= 3 { + let upper = |c: &char| c.is_uppercase(); + let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase())); + let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper)); + if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower { + out = + Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::() } else { c.to_string() }).collect()); + } else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper { + out = Some( + chars + .iter() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect() }) + .collect(), + ); + } + } + if out.is_none() && prefs.ac_days && DAYS.contains(&word) { + out = Some(ed::change_case(word, "sentence")); + } + } + if typing && prefs.ac_sentence_caps && sentence_start { + let w = out.clone().unwrap_or_else(|| word.to_string()); + let mut cs = w.chars(); + if let Some(f) = cs.next() + && f.is_lowercase() + && w.chars().all(char::is_alphabetic) + { + out = Some(f.to_uppercase().chain(cs).collect()); + } + } + out.filter(|o| o != word) +} + +/// After a space or punctuation, correct the word just typed. +pub(super) fn autocorrect(s: &mut Session, typing: bool) -> 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(()); + } + if typing && s.prefs.ac_lists { + // A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list. + let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); + if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? { + return Ok(()); + } + } + let mut word_end = end - 1; + let mut start = word_end; + while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { + start -= 1; + } + // "word," and "word." also count: the punctuation was typed before the space. + let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); + if replacement(&s.prefs, &word).is_none() { + let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c)); + if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") { + word_end -= word.chars().count() - trimmed.chars().count(); + word = trimmed.to_string(); + } + } + let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None); + let sentence_start = before.is_none_or(|c| ".!?".contains(c)); + // "word..." becomes "word…", the word corrected too. + let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some(); + let rep = if ellipsis { + let stem = word.trim_end_matches('.'); + Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string()))) + } else { + correct_word(&s.prefs, &word, sentence_start, typing) + }; + let Some(rep) = rep else { + if typing && s.prefs.ac_ordinals && is_ordinal(&word) { + return superscript_suffix(s, &t, word_end); + } + return Ok(()); + }; + let delta = rep.chars().count() as isize - word.chars().count() as isize; + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + 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(()) +} + +/// A list AutoFormat started: the text being edited right after it, the typed lead with its +/// space, the paragraph's own formatting before, and the revision the change left. +#[derive(Clone, Debug)] +pub struct AutoList { + sel: TextSel, + typed: String, + props: deckcraft_model::text::ParaProps, + revision: u64, +} + +/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and +/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering +/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`. +fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> { + let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c}))); + let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme}))); + match lead { + "*" => bullet("•"), + "-" | "–" => bullet("–"), + "--" | "—" => bullet("■"), + ">" => bullet("➢"), + "<>" => bullet("◆"), + "1." => number("arabicPeriod"), + "1)" => number("arabicParenR"), + "I." => number("romanUcPeriod"), + "i." => number("romanLcPeriod"), + "A." => number("alphaUcPeriod"), + "a." => number("alphaLcPeriod"), + "a)" => number("alphaLcParenR"), + _ => None, + } +} + +/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a +/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed +/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed. +fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result { + if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') { + return Ok(false); + } + let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default(); + let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) }; + let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); + let para = t.caret.0; + let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default(); + edit_text(s, |body, tt| { + ed::delete(body, (para, 0), (para, end)); + tt.anchor = (para, 0); + tt.caret = (para, 0); + })?; + s.execute(id, ¶ms)?; + let st = s.doc_mut()?; + if let Some(sel) = st.selection.text.clone() { + st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision }); + } + Ok(true) +} + +/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and +/// space and the formatting it had. `false` when the last change was anything else. +pub(super) fn undo_auto_list(s: &mut Session) -> Result { + let st = s.doc_mut()?; + let Some(a) = st.auto_list.take() else { return Ok(false) }; + if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) { + return Ok(false); + } + edit_text(s, |body, t| { + if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) { + p.props = a.props.clone(); + } + let c = ed::insert(body, a.sel.caret, &a.typed, None); + t.anchor = c; + t.caret = c; + })?; + Ok(true) +} + +/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat +/// superscripts it. +fn is_ordinal(word: &str) -> bool { + let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase()); + let suffix = word.get(digits.len()..).unwrap_or_default(); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 { + return false; + } + let d = digits.as_bytes(); + let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1'); + let want = match d.last() { + _ if teen => "th", + Some(b'1') => "st", + Some(b'2') => "nd", + Some(b'3') => "rd", + _ => "th", + }; + suffix == want +} + +/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix). +fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> { + let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end)); + let st = s.doc_mut()?; + if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) { + return Ok(()); + } + let mut d = (*st.doc).clone(); + let sel = st.selection.clone(); + if let Some(body) = body_mut(&mut d, &sel, t) { + ed::format(body, a, b, &|r| r.baseline = Some(0.3)); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + +pub(super) fn autocorrect_options(s: &mut Session, p: &Value) -> Result { + let pr = &mut s.prefs; + for (key, slot) in [ + ("on", &mut pr.autocorrect), + ("twoInitials", &mut pr.ac_two_initials), + ("sentenceCaps", &mut pr.ac_sentence_caps), + ("days", &mut pr.ac_days), + ("capsLock", &mut pr.ac_caps_lock), + ("fractions", &mut pr.ac_fractions), + ("ordinals", &mut pr.ac_ordinals), + ("lists", &mut pr.ac_lists), + ] { + if let Some(v) = p.get(key).and_then(Value::as_bool) { + *slot = v; + } + } + if let Some(Value::Array(a)) = p.get("add") { + let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else { + return Err(bad("text.autocorrect", "`add` is [from, to]")); + }; + let from = from.trim(); + if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 { + return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255")); + } + pr.ac_entries.retain(|(k, _)| k != from); + pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from)); + if pr.ac_entries.len() >= 2000 { + return Err(bad("text.autocorrect", "the list is full")); + } + pr.ac_entries.push((from.to_string(), to.to_string())); + } + if let Some(from) = str_param(p, "remove") { + let had = pr.ac_entries.len(); + pr.ac_entries.retain(|(k, _)| k != from); + if had == pr.ac_entries.len() + && AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from)) + && !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from)) + { + pr.ac_removed.push(from.to_string()); + } + } + let list: Vec<(String, String)> = pr + .ac_entries + .iter() + .cloned() + .chain( + AUTOCORRECT + .iter() + .filter(|(k, _)| { + !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k)) + }) + .map(|(k, v)| (k.to_string(), v.to_string())), + ) + .collect(); + Ok(json!({ + "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, + "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, + "ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list, + })) +} diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs new file mode 100644 index 000000000..c7305d62e --- /dev/null +++ b/crates/engine/src/cmd/text/caret.rs @@ -0,0 +1,396 @@ +use super::autocorrect::undo_auto_list; +use super::editing::{inherited_aligns, insert_text, promote_rtl_if_needed}; +use super::ime::{composition_range, model_pos, walk_backward, walk_forward}; +use super::layout::{ctx_for, fit_text_box, layout_for, pos_param}; +use super::{ + DocState, HistoryEntry, Pos, Result, Session, ShapeId, ShapeKind, TextBody, Value, bad, body_of, bool_or, ed, edit_shapes, edit_text, id_param, + json, ok, push_undo, str_param, xfrm_of, +}; + +/// A table cell's text laid out the way it is drawn, mapped into slide coordinates. +pub(super) fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option { + let sh = st.shape(id)?; + let ShapeKind::Table(tb) = &sh.kind else { return None }; + let cell = tb.cell(row, col)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); + let rect = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col)?.2; + let mut body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let tmp = deckcraft_model::Shape::table_cell(body.clone()); + let layout = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let to_slide = deckcraft_render::text_transform(x.affine(), rect, layout.rotation); + Some(CellText { layout, to_slide }) +} + +pub(super) struct CellText { + pub(super) layout: deckcraft_text::TextLayout, + pub(super) to_slide: deckcraft_geom::Affine, +} + +pub(super) 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 { + if dir == "backward" && undo_auto_list(s)? { + return ok(); + } + let dir = dir.to_string(); + edit_text(s, move |body, t| { + let (a, b) = t.ordered(); + let at = if dir == "wordBackward" || dir == "wordForward" { + // Ctrl+Backspace/Delete work from the start of a selection and leave the rest of it + // (PowerPoint for Windows). + ed::delete_word(body, a, dir == "wordForward") + } else if a != b { + ed::delete(body, a, b) + } else { + let other = match dir.as_str() { + "forward" => ed::char_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() +} + +/// A `before`/`after` count from a control or input-method request. +/// +/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is +/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that +/// would delete different text than asked. +pub(super) fn count_param(p: &Value, name: &str) -> usize { + p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)) +} + +/// Delete at most the requested character or byte counts around the selection or pre-edit, +/// preserving selection direction and composition boundaries and recording one undo step +/// outside composition. +pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let before = count_param(p, "before"); + let after = count_param(p, "after"); + let bytes = bool_or(p, "bytes", false); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?; + let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?; + let comp = composition_range(body, st.composition.as_ref()); + // The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a + // paragraph separator and for a soft line break. A range may therefore cross paragraphs. + let ordered = { + let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; + let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; + if a <= b { (a, b) } else { (b, a) } + }; + // The selection (or the input method's pre-edit) stays exactly where it is. Only whole + // scalars fully inside the requested before/after ranges around it are removed. + let (start, end) = match comp { + Some((pi, cs, ce)) => ((pi, cs), (pi, ce)), + None => ordered, + }; + let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes); + let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes); + let deleted = before_chars + after_chars; + if deleted == 0 { + return Ok(json!({"deleted": 0})); + } + let before_doc = st.doc.clone(); + let before_sel = st.selection.clone(); + let composing = st.composition.is_some(); + let vertex_kind = s.tool.vertex_kind.clone(); + edit_text(s, |body, t| { + // Delete the after-range first, so the positions of the earlier range stay valid; then + // the text before the selection. + if after_chars > 0 { + ed::delete(body, end, to); + } + if before_chars > 0 { + ed::delete(body, from, start); + } + t.anchor = remap_after_delete(t.anchor, start, from, before_chars); + t.caret = remap_after_delete(t.caret, start, from, before_chars); + t.line_end = None; + })?; + if composing { + if let Some(c) = s.doc_mut()?.composition.as_mut() { + c.start = if before_chars == 0 { + start + } else if from.0 == start.0 { + (start.0, start.1 - before_chars) + } else { + from + }; + } + } else { + push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind }); + } + Ok(json!({"deleted": deleted})) +} + +/// Map a text position across the deletion of `before_chars` characters that begins at `start` +/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier +/// paragraph those paragraphs merge, so later paragraph numbers shift too. +fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) { + if before_chars == 0 || pos < start { + return pos; + } + if from.0 == start.0 { + // Only endpoints in the start paragraph move; later paragraphs keep their text. + if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos } + } else { + (from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 }) + } +} + +pub(super) fn delete(s: &mut Session, p: &Value) -> Result { + delete_dir(s, str_param(p, "dir").unwrap_or("backward")) +} + +/// Is paragraph `para` right-to-left (its lines run from the right)? +fn para_rtl(layout: Option<&deckcraft_text::TextLayout>, body: &TextBody, para: usize) -> bool { + match layout.and_then(|l| l.lines.iter().find(|x| x.para == para)) { + Some(line) => line.rtl, + None => body.paragraphs.get(para).and_then(|x| x.props.rtl).unwrap_or(false), + } +} + +pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { + use deckcraft_text::Affinity; + 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| deckcraft_text::Pos { para: c.0, ch: c.1 }; + let caret = t.caret; + let aff = if t.at_line_end() { Affinity::Upstream } else { Affinity::Downstream }; + // Left and right are on screen: in a right-to-left paragraph left goes forward in the text. + let rtl = para_rtl(layout.as_ref(), &body, caret.0); + let to = match (to.as_str(), rtl) { + ("left", true) => "right", + ("right", true) => "left", + ("wordLeft", true) => "wordRight", + ("wordRight", true) => "wordLeft", + (x, _) => x, + } + .to_string(); + let collapse_to = |left: bool| if left { t.ordered().0 } else { t.ordered().1 }; + let mut upstream = false; + 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), + // Ctrl+arrows with a selection go on from its start (left) or end (right). + "wordLeft" => ed::word_move(&body, if extend { caret } else { collapse_to(true) }, false), + "wordRight" => ed::word_move(&body, if extend { caret } else { collapse_to(false) }, true), + "paraStart" => (caret.0, 0), + "paraEnd" => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)), + "paraUp" => ed::para_up(&body, caret), + "paraDown" => ed::para_down(&body, caret), + "start" => (0, 0), + "end" => ed::end(&body), + "up" | "down" | "lineStart" | "lineEnd" => match &layout { + Some(l) => { + let x = l.caret_drawn(lp(caret), aff).map(|c| c.0).unwrap_or(0.0); + let (r, a) = match to.as_str() { + "up" => l.vertical_affine(lp(caret), aff, false, x), + "down" => l.vertical_affine(lp(caret), aff, true, x), + "lineStart" => (l.line_bounds_affine(lp(caret), aff).0, Affinity::Downstream), + // End stays on its line: after a soft wrap the caret sits at the line's end. + _ => l.line_end_caret(lp(caret), aff), + }; + upstream = a == Affinity::Upstream; + (r.para, r.ch) + } + None => match to.as_str() { + "up" => (caret.0.saturating_sub(1), caret.1), + "down" => (caret.0.saturating_add(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; + tt.line_end = if upstream { Some(new) } else { None }; + if !extend { + tt.anchor = new; + } + } + })?; + Ok(json!({"caret": [new.0, new.1], "lineEnd": upstream})) +} + +pub(super) 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() +} + +pub(super) 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() +} + +pub(super) 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() +} + +pub(super) 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 body = body_of(st, &t); + // Clamp to the last paragraph so a huge index can neither overflow nor select past the body. + let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1); + let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0); + // The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too). + let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) }; + s.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.anchor = (at.0, 0); + t.caret = end; + } + })?; + ok() +} + +/// Turn leading tabs of a paragraph into its outline level (capped at 8) and strip them. +/// Returns whether any tab was found. +pub(crate) fn apply_leading_tabs(para: &mut deckcraft_model::text::Paragraph) -> bool { + let tabs = para.runs.first().map(|r| r.text.chars().take_while(|c| *c == '\t').count()).unwrap_or(0); + if tabs == 0 { + return false; + } + if let Some(r) = para.runs.first_mut() { + r.text = r.text.trim_start_matches('\t').to_string(); + } + para.level = tabs.min(8) as u8; + true +} + +pub(super) 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", "text.set")? { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?, + }; + let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))); + let inherited = inherited_aligns(s, id); + edit_shapes(s, &json!({"id": id}), "text.set", |sh| { + let target = match (&mut sh.kind, cell) { + (ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text), + (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, deckcraft_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. + if !apply_leading_tabs(para) + && let Some((lv, pp)) = levels.get(i) + { + para.level = *lv; + para.props = pp.clone(); + } + promote_rtl_if_needed(para, &inherited); + for r in &mut para.runs { + r.props = keep.clone(); + } + } + body.paragraphs = nb.paragraphs; + Ok(()) + })?; + fit_text_box(s, id)?; + ok() +} + +pub(super) fn get(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = match id_param(p, "id", "text.get")? { + 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::>()})) +} + +#[cfg(test)] +mod select_paragraph_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn select_paragraph_huge_at() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap(); + s.execute("text.edit", &json!({})).unwrap(); + let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]})); + assert!(r.is_ok(), "{r:?}"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + // Clamped to the last (only) paragraph, never past the end of the body. + assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5))); + } +} diff --git a/crates/engine/src/cmd/text/editing.rs b/crates/engine/src/cmd/text/editing.rs new file mode 100644 index 000000000..7e2c37cb4 --- /dev/null +++ b/crates/engine/src/cmd/text/editing.rs @@ -0,0 +1,220 @@ +use super::autocorrect::autocorrect; +use super::layout::{ctx_for, pos_param}; +use super::{ + Align, Pos, Result, Run, RunKind, RunProps, Session, ShapeId, ShapeKind, TextBody, TextSel, Value, bad, body_of, bool_or, ed, edit_text, + id_param, json, ok, resolve, shape_of, +}; + +pub(super) 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(); + sel.cells = None; + })?; + return ok(); + } + let id = match id_param(p, "id", "text.edit")? { + Some(i) => i, + None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.edit", "select a shape with text"))?, + }; + 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) = deckcraft_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, line_end: None }); + sel.cells = None; + })?; + Ok(json!({"id": id, "at": [at.0, at.1]})) +} + +pub(super) 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 inherited = inherited_aligns_for_edit(s); + 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 { unlinked_at_link_end(body, at) }); + let c = ed::insert(body, at, &text, props); + t.anchor = c; + t.caret = c; + let last = body.paragraphs.len().saturating_sub(1); + for pi in at.0..=c.0.min(last) { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para, &inherited); + } + } + c + })?; + autocorrect(s, text.chars().count() == 1)?; + // AutoCorrect can move the caret: a replaced word, or a list lead it removed. + let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret); + Ok(json!({"caret": [pos.0, pos.1]})) +} + +/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an +/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false` +/// when the shape can't be resolved (notes, a shape that is gone). +pub(super) fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] { + let mut out = [false; 9]; + let Ok(st) = s.doc() else { return out }; + let Some(sh) = st.shape(id) else { return out }; + let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out }; + for (lvl, set) in out.iter_mut().enumerate() { + let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() }; + *set = resolve::para(&ctx, sh, ¶).align.is_some(); + } + out +} + +/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes). +fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] { + match s.doc().ok().and_then(|st| st.selection.text.clone()) { + Some(t) if !t.notes => inherited_aligns(s, t.shape), + _ => [false; 9], + } +} + +/// A paragraph with no direction of its own whose first strong character is right-to-left +/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the +/// paragraph or what it inherits, `inherited`) sets its alignment. +pub(super) fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) { + if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { + para.props.rtl = Some(true); + let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false); + if para.props.align.is_none() && !inherits_align { + para.props.align = Some(Align::Right); + } + } +} + +/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked +/// run, the props to type with are the run's without the link. `None` elsewhere. +pub(super) fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { + let mut props = ed::props_at(body, at); + props.link.as_ref()?; + let len = body.paragraphs.get(at.0).map_or(0, |p| p.text().chars().count()); + // props_at(c + 1) is the props of the character at c. + if at.1 < len && ed::props_at(body, (at.0, at.1 + 1)).link == props.link { + return None; + } + props.link = None; + Some(props) +} + +pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { + let inherited = inherited_aligns_for_edit(s); + let pos = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let c = ed::insert_body(body, at, src); + t.anchor = c; + t.caret = c; + let last = body.paragraphs.len().saturating_sub(1); + for pi in at.0..=c.0.min(last) { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para, &inherited); + } + } + 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![deckcraft_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() +} + +#[cfg(test)] +mod rtl_promote_tests { + use deckcraft_model::text::{Align, Paragraph, Run}; + + use super::promote_rtl_if_needed; + + fn para(text: &str) -> Paragraph { + Paragraph { runs: vec![Run::new(text)], ..Default::default() } + } + + #[test] + fn right_aligns_only_when_no_alignment_is_set_or_inherited() { + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right))); + let mut p = para("مرحبا"); + promote_rtl_if_needed(&mut p, &[true; 9]); + assert_eq!((p.props.rtl, p.props.align), (Some(true), None)); + let mut p = para("Hello مرحبا"); + promote_rtl_if_needed(&mut p, &[false; 9]); + assert_eq!((p.props.rtl, p.props.align), (None, None)); + } +} diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs new file mode 100644 index 000000000..158af0962 --- /dev/null +++ b/crates/engine/src/cmd/text/formatting.rs @@ -0,0 +1,246 @@ +use super::layout::fit_text_box; +use super::{ + DocState, Hyperlink, Pos, Result, RunProps, SELECT_TEXT_OR_SHAPE, Session, ShapeId, ShapeKind, TextBody, TextSel, Value, bad, body_of, ed, + edit_shapes, edit_text, json, ok, str_param, +}; + +/// 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 (a, b) = format_range(st, &t); + 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_SHAPE)); + } + let cells = st.selection.cells; + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + let id = sh.id; + match &mut sh.kind { + ShapeKind::Table(t) => { + // Only the selected cells, when a range of them is selected. + let range = crate::cmd::table::selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if crate::cmd::table::in_range(range, r, c) { + ed::format_all(&mut cell.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() +} + +/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in +/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink +/// edits this same span, so the dialog reads it too. +pub fn format_range(st: &DocState, t: &TextSel) -> (Pos, Pos) { + let body = body_of(st, t).cloned().unwrap_or_default(); + let (mut a, mut b) = t.ordered(); + if a == b { + 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; + } + } + } + (a, b) +} + +/// Link the text being edited: the selection or the word at the caret, as `format_run` would. +/// With nothing to link (caret at a word boundary, empty paragraph) `display` is inserted at the +/// caret as linked text in the surrounding formatting, as PowerPoint's Insert Hyperlink does. +pub(crate) fn link_text(s: &mut Session, link: Hyperlink, display: &str) -> Result { + let st = s.doc()?; + let Some(t) = st.selection.text.clone() else { return Err(bad("insert.hyperlink", "not editing text")) }; + let (a, b) = format_range(st, &t); + if a != b { + return format_run(s, &move |rp| rp.link = Some(link.clone())); + } + if display.is_empty() { + return Err(bad("insert.hyperlink", "nothing to link: select text or give `text`")); + } + let pos = edit_text(s, |body, t| { + let mut props = ed::props_at(body, a); + props.link = Some(link); + let c = ed::insert(body, a, display, Some(props)); + t.anchor = c; + t.caret = c; + c + })?; + Ok(json!({"caret": [pos.0, pos.1]})) +} + +/// 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 deckcraft_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()); + } + let last = body.paragraphs.len().saturating_sub(1); + for pi in a.0..=b.0.min(last) { + 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_SHAPE)); + } + let cells = st.selection.cells; + edit_shapes(s, &json!({"ids": ids}), "format", |sh| { + let id = sh.id; + match &mut sh.kind { + ShapeKind::Table(t) => { + let range = crate::cmd::table::selected_range(cells, id, t); + for (r, row) in t.rows.iter_mut().enumerate() { + for (c, cell) in row.cells.iter_mut().enumerate() { + if crate::cmd::table::in_range(range, r, c) { + cell.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 deckcraft_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() +} + +pub(super) fn fonts_used(s: &mut Session, _: &Value) -> Result { + let mut v = crate::fonts::used_fonts(&s.doc()?.doc); + v.sort_by_key(|f| f.to_lowercase()); + Ok(json!(v)) +} + +pub(super) fn replace_font(s: &mut Session, p: &Value) -> Result { + let from = str_param(p, "from").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `from`"))?.to_string(); + let to = str_param(p, "to").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad("font.replace", "missing `to`"))?.to_string(); + if to.len() > 256 || to.chars().any(char::is_control) { + return Err(bad("font.replace", "`to` is not a font name")); + } + // Count on a copy first so a replace that finds nothing leaves no undo step. + let mut probe = (*s.doc()?.doc).clone(); + if crate::fonts::replace_font(&mut probe, &from, &to) == 0 { + return Ok(json!({"replaced": 0})); + } + let n = s.edit(|doc, _| Ok(crate::fonts::replace_font(doc, &from, &to)))?; + let st = s.doc_mut()?; + st.missing_fonts = crate::fonts::missing_fonts(&st.doc); + Ok(json!({"replaced": n})) +} + +#[cfg(test)] +mod replace_font_tests { + use serde_json::json; + + use crate::Session; + + fn fonts(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("font.used", &json!({})).unwrap()).unwrap() + } + + #[test] + fn replaces_runs_tables_notes_and_theme_and_undoes() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap(); + let theme = s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin.clone(); + s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "Hello"})).unwrap(); + s.execute("text.exit", &json!({})).unwrap(); + s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue"), "{:?}", fonts(&mut s)); + let r = s.execute("font.replace", &json!({"from": "comic neue", "to": "Lato"})).unwrap(); + assert!(r["replaced"].as_u64().unwrap() >= 1, "{r}"); + let used = fonts(&mut s); + assert!(!used.iter().any(|f| f == "Comic Neue") && used.iter().any(|f| f == "Lato"), "{used:?}"); + // The theme's body font is replaced too, so placeholders follow. + s.execute("font.replace", &json!({"from": theme, "to": "Lato"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.masters[0].theme.fonts.minor.latin, "Lato"); + assert!(s.doc().unwrap().doc.validate().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(fonts(&mut s).iter().any(|f| f == "Comic Neue")); + } + + #[test] + fn nothing_to_replace_is_no_undo_step_and_bad_names_fail() { + let mut s = Session::with_new(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("font.replace", &json!({"from": "Nope Sans", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.execute("font.replace", &json!({"from": "+mn-lt", "to": "Inter"})).unwrap()["replaced"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert!(s.execute("font.replace", &json!({"from": "", "to": "Inter"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter", "to": "a\nb"})).is_err()); + assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err()); + } +} diff --git a/crates/engine/src/cmd/text/ime.rs b/crates/engine/src/cmd/text/ime.rs new file mode 100644 index 000000000..274995467 --- /dev/null +++ b/crates/engine/src/cmd/text/ime.rs @@ -0,0 +1,620 @@ +use super::caret::cell_on_slide; +use super::editing::unlinked_at_link_end; +use super::layout::{ctx_for, layout_for}; +use super::{DocState, Paragraph, Result, Session, TextBody, TextSel, Value, bad, body_of, ed, edit_text, json, str_param, xfrm_of}; + +/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query. +/// +/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the +/// engine answer without copying a whole body or document. +pub const IME_SURROUNDING_MAX_BYTES: usize = 4000; + +/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8 +/// byte offsets. The current input-method composition (pre-edit) is removed from both the text +/// and the offsets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + pub text: String, + pub cursor: usize, + pub anchor: usize, +} + +/// How a soft line break reads in the IME view: a newline, like a paragraph break. +/// +/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would +/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines +/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change. +fn ime_char(ch: char) -> char { + if ch == '\u{b}' { '\n' } else { ch } +} + +/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`. +fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { + p.runs + .iter() + .flat_map(|run| run.content().chars()) + .enumerate() + .filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))) +} + +/// The same characters, newest first, without walking the paragraph from its start. +fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { + let mut starts = Vec::with_capacity(p.runs.len()); + let mut at = 0usize; + for r in &p.runs { + let n = r.char_len(); + starts.push((at, n)); + at += n; + } + p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| { + let content = r.content(); + content.chars().rev().enumerate().filter_map(move |(k, ch)| { + let i = start + n - 1 - k; + (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))) + }) + }) +} + +/// The composition's `(paragraph, start, end)` character range, when it is inside the body. +pub(super) fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> { + let c = c?; + let para = body.paragraphs.get(c.start.0)?; + let start = c.start.1.min(para.char_len()); + let end = start.saturating_add(c.len).min(para.char_len()); + (start < end).then_some((c.start.0, start, end)) +} + +/// Return the composition character range only when its paragraph index equals `pi`. +fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> { + comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e)) +} + +/// Clip a model position to the body. An empty body only has position `(0, 0)`. +pub(super) fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> { + if body.paragraphs.is_empty() { + return (pos == (0, 0)).then_some((0, 0)); + } + body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len()))) +} + +/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole +/// characters), stopping at the end of the body. Returns the position reached and the characters +/// and bytes consumed. +/// +/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks +/// that paragraph's runs from their start: runs do not cache character lengths, and a position is +/// a character index. A megabyte paragraph therefore costs time proportional to the caret's +/// character offset, even though the copied surrounding text stays within `limit`. +pub(super) fn walk_forward( + body: &TextBody, + comp: Option<(usize, usize, usize)>, + from: (usize, usize), + limit: usize, + bytes: bool, +) -> ((usize, usize), usize, usize) { + let (mut pi, mut at) = from; + let (mut chars, mut used) = (0usize, 0usize); + // `limit` comes from a (possibly hostile) request: every count is checked, never added + // blindly, so an enormous limit cannot overflow. + let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; + while let Some(para) = body.paragraphs.get(pi) { + for (i, ch) in committed_chars(para, comp_in(comp, pi)) { + if i < at { + continue; + } + let n = ch.len_utf8(); + if !fits(used, chars, n) { + return ((pi, i), chars, used); + } + used += n; + chars += 1; + at = i + 1; + } + if pi + 1 >= body.paragraphs.len() { + break; + } + // The paragraph separator between this paragraph and the next. + if !fits(used, chars, 1) { + return ((pi, at), chars, used); + } + used += 1; + chars += 1; + pi += 1; + at = 0; + } + ((pi, at), chars, used) +} + +/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole +/// characters). Returns the position reached and the characters and bytes consumed. +pub(super) fn walk_backward( + body: &TextBody, + comp: Option<(usize, usize, usize)>, + from: (usize, usize), + limit: usize, + bytes: bool, +) -> ((usize, usize), usize, usize) { + let (mut pi, mut at) = from; + let (mut chars, mut used) = (0usize, 0usize); + let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; + loop { + let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) }; + let mut boundary = at.min(para.char_len()); + for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) { + if i >= boundary { + continue; + } + let n = ch.len_utf8(); + if !fits(used, chars, n) { + return ((pi, boundary), chars, used); + } + used += n; + chars += 1; + boundary = i; + } + // This paragraph is consumed down to its start. + if pi == 0 { + return ((0, 0), chars, used); + } + if !fits(used, chars, 1) { + return ((pi, 0), chars, used); + } + used += 1; + chars += 1; + pi -= 1; + at = usize::MAX; // all of the previous paragraph is before us + } +} + +/// The committed logical text between two model positions (excluding `end`). `None` when it is +/// longer than `cap` bytes: every caller only needs a bounded answer. +fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option { + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let mut out = String::new(); + for pi in a.0..=b.0 { + let Some(para) = body.paragraphs.get(pi) else { return Some(out) }; + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { usize::MAX }; + for (i, ch) in committed_chars(para, comp_in(comp, pi)) { + if i < from || i >= to { + continue; + } + out.push(ch); + if out.len() > cap { + return None; + } + } + if pi < b.0 { + out.push('\n'); + if out.len() > cap { + return None; + } + } + } + Some(out) +} + +/// Committed surrounding text for the text being edited. +/// +/// Returns `None` when the body or selection is invalid, or when the selection alone is wider +/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct +/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection. +/// +/// Only a bounded window is measured and copied: the query walks at most the budget out from the +/// selection on each side and does not copy the rest of the body. Finding the selection's +/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not +/// bounded independently of the paragraph length. +pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option { + let body = body_of(st, t)?; + let comp = composition_range(body, st.composition.as_ref()); + let caret = model_pos(body, t.caret)?; + let anchor = model_pos(body, t.anchor)?; + let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) }; + let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?; + let selection_len = selected.len(); + let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len); + let (_, _, before_available) = walk_backward(body, comp, start, budget, true); + let (_, _, after_available) = walk_forward(body, comp, end, budget, true); + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let (window_start, _, _) = walk_backward(body, comp, start, before, true); + let (window_end, _, _) = walk_forward(body, comp, end, after, true); + let before_text = logical_between(body, comp, window_start, start, budget)?; + let after_text = logical_between(body, comp, end, window_end, budget)?; + let before_len = before_text.len(); + let mut text = before_text; + text.push_str(&selected); + text.push_str(&after_text); + // The budget arithmetic above already bounds this; refuse rather than publish more. + if text.len() > IME_SURROUNDING_MAX_BYTES { + return None; + } + // The caret and anchor are the selection's own endpoints, so their offsets inside the + // window are the window's start plus either end of the selected text. + let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) }; + Some(ImeSurrounding { text, cursor, anchor }) +} + +/// Text an input method sends: no paragraph or line breaks (Enter is a key of its own). +fn ime_text(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).collect() +} + +/// Is the composition still where it was put (the paragraph holds all of it)? +fn composition_fits(st: &DocState, c: &crate::Composition) -> bool { + let Some(t) = &st.selection.text else { return false }; + let Some(body) = body_of(st, t) else { return false }; + body.paragraphs.get(c.start.0).is_some_and(|p| c.start.1.saturating_add(c.len) <= p.char_len()) +} + +/// `text.compose`: put an input method's preedit at the insertion point (replacing the selection) +/// or replace the composition with the new preedit. An empty preedit cancels: the presentation +/// goes back to how it was before the composition began. +pub(super) fn compose(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.compose", "missing `text`"))?); + let n = text.chars().count(); + let active = p.get("active").and_then(Value::as_array).and_then(|a| { + let (x, y) = (a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize); + (x < y).then_some((x.min(n), y.min(n))) + }); + let current = s.doc()?.composition.clone(); + // A composition whose text was changed under it is kept as it is; a new one starts. + let current = match current { + Some(c) if !composition_fits(s.doc()?, &c) => { + // The text changed under the pre-edit: the app settled it, so the platform's copy + // must be cancelled too. + s.settle_composition(); + None + } + c => c, + }; + match current { + None if text.is_empty() => Ok(json!({"composing": false})), + None => { + let st = s.doc()?; + let (before, before_selection) = (st.doc.clone(), st.selection.clone()); + let (start, props) = edit_text(s, |body, t| { + let (a, b) = t.ordered(); + let at = ed::delete(body, a, b); + let props = if a != b { Some(ed::props_at(body, at)) } else { unlinked_at_link_end(body, at) }; + let c = ed::insert(body, at, &text, props.clone()); + t.anchor = c; + t.caret = c; + (at, props) + })?; + s.doc_mut()?.composition = Some(crate::Composition { start, len: n, active, props, before, before_selection }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + Some(c) if text.is_empty() => { + let st = s.doc_mut()?; + st.doc = c.before; + st.selection = c.before_selection; + st.composition = None; + st.revision += 1; + Ok(json!({"composing": false})) + } + Some(c) => { + let (start, len, props) = (c.start, c.len, c.props.clone()); + edit_text(s, |body, t| { + ed::delete(body, start, (start.0, start.1 + len)); + let e = ed::insert(body, start, &text, props); + t.anchor = e; + t.caret = e; + })?; + s.doc_mut()?.composition = Some(crate::Composition { len: n, active, ..c }); + Ok(json!({"composing": true, "start": [start.0, start.1], "len": n})) + } + } +} + +/// `text.commit`: the input method's result replaces the composition (or is typed at the +/// insertion point when there is none) and the whole composition becomes one undo step. +pub(super) fn commit(s: &mut Session, p: &Value) -> Result { + let text = ime_text(str_param(p, "text").ok_or_else(|| bad("text.commit", "missing `text`"))?); + if s.doc()?.composition.is_some() || !text.is_empty() { + compose(s, &json!({"text": text}))?; + } + s.end_composition(); + let caret = s.doc()?.selection.text.as_ref().map(|t| t.caret).unwrap_or_default(); + Ok(json!({"caret": [caret.0, caret.1]})) +} + +/// Underlines (slide coordinates) under character ranges `(para, start, end)` of shape `id`'s +/// text as laid out on the current slide: where spelling squiggles go. +pub fn range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let Some(l) = layout_for(st, &TextSel { shape: id, ..Default::default() }) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + +/// Like [`range_underlines`], for the text of table `id`'s cell at `row`, `col`. +pub fn cell_range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + row: usize, + col: usize, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { return vec![] }; + let (Some(cell), Some(ctx)) = (tb.cell(row, col), ctx_for(&st.doc, &st.selection)) else { return vec![] }; + let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); + let Some(rect) = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col).map(|(_, _, r)| r) else { + return vec![]; + }; + let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + let tmp = deckcraft_model::Shape::table_cell(body.clone()); + let l = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + +/// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, +/// thick)` per line, thick under the part being converted. Empty without a composition, or for +/// notes (an egui text field draws its own composition). +pub fn composition_underlines(st: &DocState) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { + let (Some(c), Some(t)) = (&st.composition, &st.selection.text) else { return vec![] }; + if t.notes { + return vec![]; + } + if let Some((row, col)) = t.cell { + let Some(placed) = cell_on_slide(st, t.shape, row, col) else { return vec![] }; + return underline_segments(&placed.layout, placed.to_slide, c); + } + let Some(sh) = st.shape(t.shape) else { return vec![] }; + let Some(l) = layout_for(st, t) else { return vec![] }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + underline_segments(&l, m, c) +} + +fn underline_segments( + layout: &deckcraft_text::TextLayout, + to_slide: deckcraft_geom::Affine, + c: &crate::Composition, +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point, bool)> { + let pos = |ch: usize| deckcraft_text::Pos { para: c.start.0, ch: c.start.1 + ch }; + let mut out = vec![]; + let mut add = |a: usize, b: usize, thick: bool| { + for (x0, x1, y) in layout.composition_underlines(pos(a), pos(b)) { + out.push((to_slide * deckcraft_geom::Point::new(x0, y), to_slide * deckcraft_geom::Point::new(x1, y), thick)); + } + }; + add(0, c.len, false); + if let Some((a, b)) = c.active { + add(a, b.min(c.len), true); + } + out +} + +#[cfg(test)] +mod ime_surrounding_tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use serde_json::json; + + use crate::Session; + + /// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id. + fn edit(s: &mut Session) -> u64 { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + id + } + + /// Test helper: return the identified shape's text, or an empty string when it is gone. + fn text(s: &Session, id: u64) -> String { + let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap()); + s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default() + } + + /// Test helper: set an in-paragraph text selection through the engine command path. + fn select(s: &mut Session, anchor: usize, caret: usize) { + s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap(); + } + + /// DeleteSurrounding removes only the requested text around the selection. The selection + /// itself and its direction stay, so the following commit replaces it. + #[test] + fn selected_text_survives_the_requested_characters_or_bytes_around_it() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀 + s.delete_surrounding(1, 1, false).unwrap(); + assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + + select(&mut s, 1, 3); + s.delete_surrounding(1, 1, true).unwrap(); + assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + } + + /// A byte range removes only whole scalars fully contained in it. A count that ends inside + /// a character leaves that character whole (and undeleted), never splitting it. + #[test] + fn byte_counts_round_inward_to_whole_characters() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 2, 2); // after é: bytes 0..3 + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar"); + s.delete_surrounding(2, 0, true).unwrap(); + assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + + select(&mut s, 2, 2); + s.delete_surrounding(0, 1, true).unwrap(); + assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar"); + s.delete_surrounding(0, 4, true).unwrap(); + assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji"); + } + + /// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed + /// selection, removes the model-stored pre-edit, and treats an empty body as available. + #[test] + fn surrounding_text_is_committed_logical_and_bounded() { + use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding}; + + let mut s = Session::with_new(); + let id = edit(&mut s); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8)); + + s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor"); + assert_eq!(q.text, "aé😀b"); + + s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + s.compose_text("日本", None).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text"); + + s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available"); + + let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500); + s.execute("text.set", &json!({"id": id, "text": long})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len()); + assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window"); + } + + /// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may + /// remove one and merge the paragraphs, exactly as the published offsets say. + #[test] + fn paragraph_marks_are_deletable_logical_text() { + use super::ime_surrounding; + + let mut s = Session::with_new(); + let id = edit(&mut s); + s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap(); + // The caret at the start of the second paragraph: after "ab" and its paragraph mark. + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); + let st = s.doc().unwrap(); + let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3)); + + // One byte before the caret is that paragraph mark. + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "ab\ncd"); + + // Characters count the same mark. + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); + s.delete_surrounding(1, 0, false).unwrap(); + assert_eq!(text(&s, id), "abcd"); + s.execute("edit.undo", &json!({})).unwrap(); + + // And from the other side: one character after the end of the first paragraph. + s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); + s.delete_surrounding(0, 1, false).unwrap(); + assert_eq!(text(&s, id), "abcd"); + } + + /// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the + /// platform must never see the model's `\u{b}`, which its own text filter would reject. + #[test] + fn soft_line_breaks_read_as_newlines() { + use super::ime_surrounding; + + let mut s = Session::with_new(); + let id = edit(&mut s); + s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates + + s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap(); + assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph"); + let st = s.doc().unwrap(); + let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2)); + + // The break is one logical byte, so removing it removes one character. + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "ab"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "a\u{b}b"); + } + + /// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a + /// different number that would delete other text than the platform asked for. + #[test] + fn out_of_range_counts_clamp_instead_of_truncating() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 1, 1); + s.delete_surrounding(usize::MAX, 0, false).unwrap(); + assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic"); + let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX); + assert_eq!(super::super::caret::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); + assert!(expected > 0, "the count is clamped, not wrapped to nothing"); + assert_eq!(super::super::caret::count_param(&json!({}), "before"), 0); + } + + /// A non-composing input-method deletion is exactly one undo step; the manual history entry + /// must not be duplicated by the generic command wrapper. + #[test] + fn a_non_composing_ime_deletion_is_one_undo_step() { + let mut s = Session::with_new(); + let id = edit(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + select(&mut s, 3, 3); + s.delete_surrounding(1, 0, false).unwrap(); + assert_eq!(text(&s, id), "aéb"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + } +} diff --git a/crates/engine/src/cmd/text/layout.rs b/crates/engine/src/cmd/text/layout.rs new file mode 100644 index 000000000..866eb7363 --- /dev/null +++ b/crates/engine/src/cmd/text/layout.rs @@ -0,0 +1,201 @@ +use super::caret::cell_on_slide; +use super::{ + Affine, Arc, AutoFit, Ctx, DocState, Paragraph, Point, Pos, Presentation, Rect, Result, Selection, Session, Shape, ShapeId, Target, TextBody, + TextSel, Value, ed, resolve, xfrm_of, +}; + +/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout +/// computes (#724), then 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<()> { + fit_shrink_scale(s, id)?; + 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 = deckcraft_render::shape_geometry(&sh, x.w, x.h); + let fields = deckcraft_text::NoFields; + let mut nx = x; + if bp.wrap == Some(false) { + nx.w = deckcraft_text::fit_width(&ctx, &sh, &body, geo.text_rect, &fields).max(20.0); + } + let geo = deckcraft_render::shape_geometry(&sh, nx.w, nx.h); + let need = deckcraft_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) = deckcraft_model::find_shape_mut(list, id) + { + target.xfrm = Some(nx); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + +/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps +/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text +/// change: a body with no meaningful saved scale — a file's bare `` (`None`) or +/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is +/// left as it is, as PowerPoint draws it. +fn fit_shrink_scale(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 Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) }; + if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) { + return Ok(()); + } + let doc = st.doc.clone(); + let sel = st.selection.clone(); + let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) }; + let x = xfrm_of(&doc, &sel, &sh); + let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect; + // Lay out as if the scale had just been reset, so the fit is computed even for `None` (which + // is drawn at 100 % and only fitted again when its text changes). + let mut probe = body.clone(); + probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }); + let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }; + let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts); + if l.font_scale >= 1.0 && l.line_reduction <= 0.0 { + return Ok(()); + } + // Change the body in place without an extra undo step (it is part of the edit). + let st = s.doc_mut()?; + let mut d = (*st.doc).clone(); + let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection) + && let Some(target) = deckcraft_model::find_shape_mut(list, id) + && let Some(t) = target.text.as_mut() + && let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit + { + (*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction); + true + } else { + false + }; + if stored { + 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)), + t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).map(|m| Ctx::for_master(doc, m)), + Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout).map(|l| Ctx::for_layout(doc, m, l))), + } +} + +/// Lay out the text being edited (for vertical caret movement and hit testing). +pub fn layout_for(st: &DocState, t: &TextSel) -> Option { + text_frame(st, t).map(|f| f.0) +} + +/// The text being edited as it is drawn: its layout, and the transform from layout space to the +/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the +/// caret, the text selection, hit testing and caret movement all use it. `None` for notes. +pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> { + if t.notes { + return None; + } + let sh = st.shape(t.shape)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let lay = |s: &Shape, body: &TextBody, rect: Rect| { + deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }) + }; + if let Some((r, c)) = t.cell { + // A cell's text is laid out in the cell's rect with the cell's margins, anchor and the + // table style's text look, exactly as the table draws it. + let placed = cell_on_slide(st, t.shape, r, c)?; + return Some((placed.layout, placed.to_slide)); + } + let body = sh.text.as_ref()?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect; + let l = lay(sh, body, tr); + let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation); + Some((l, m)) +} + +/// The text position under slide point `p` in the text being edited (its shape or cell). +pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos { + match text_frame(st, t) { + Some((l, m)) => { + let h = l.hit(m.inverse() * p); + (h.para, h.ch) + } + None => (0, 0), + } +} + +/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and +/// vertical text as drawn). Text with no paragraph yet (a placeholder about to be typed in) is +/// measured as one empty paragraph, so the caret shows where typing begins. `None` for notes, +/// which are an egui text field. +pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point, deckcraft_geom::Point)> { + if t.notes { + return None; + } + if let Some((row, col)) = t.cell { + let placed = cell_on_slide(st, t.shape, row, col)?; + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = placed.layout.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + return Some((placed.to_slide * deckcraft_geom::Point::new(cx, top), placed.to_slide * deckcraft_geom::Point::new(cx, bot))); + } + let sh = st.shape(t.shape)?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let x = xfrm_of(&st.doc, &st.selection, sh); + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let mut body = sh.text.clone().unwrap_or_default(); + if body.paragraphs.is_empty() { + body.paragraphs.push(deckcraft_model::text::Paragraph::default()); + } + let l = deckcraft_text::layout( + &ctx, + sh, + &body, + &deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + // After End on a wrapped line the caret is drawn at that line's end. + let aff = if t.at_line_end() { deckcraft_text::Affinity::Upstream } else { deckcraft_text::Affinity::Downstream }; + let (cx, top, bot) = l.caret_drawn(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }, aff)?; + let m = deckcraft_render::text_transform(x.affine(), geo.text_rect, l.rotation); + Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot))) +} + +/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows +/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text +/// has no fill to take a colour from. +pub fn caret_color(st: &DocState, t: &TextSel) -> Option { + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + let body = sh.text.as_ref()?; + let ctx = ctx_for(&st.doc, &st.selection)?; + let blank = Paragraph::default(); + let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank); + let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret)); + Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0) +} + +pub(super) 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)) +} diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index bcda8492a..c2fb8a3c5 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -468,8 +468,20 @@ mod tests { continue; } let text = std::fs::read_to_string(&path).unwrap(); - // Test modules (which build their own fixtures) come last in each file. - let code = text.split("#[cfg(test)]").next().unwrap_or(""); + // Test modules (which build their own fixtures) come last in each file; a + // `#[cfg(test)]` import near the top doesn't start them. + let mut code = text.as_str(); + let mut from = 0; + while let Some(i) = text[from..].find("#[cfg(test)]") { + let at = from + i; + let next = text[at + "#[cfg(test)]".len()..].trim_start(); + if ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|p| next.starts_with(p)) { + from = at + 1; + continue; + } + code = &text[..at]; + break; + } for (n, line) in code.lines().enumerate() { let line = line.trim_start(); let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w)); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 2325cb19a..78c6ed35c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -11,6 +11,14 @@ use serde_json::{Value, json}; use crate::cmd::{self, xfrm_of}; use crate::{HistoryEntry, Result, Session, TextSel}; +mod click; +mod drag; +mod geometry; +mod hit; +mod pointer; + +pub use hit::{line_xfrm, resize_box, table_rows, text_pos}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum PointerKind { @@ -359,1740 +367,6 @@ fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f text.body.warp_adj = adj; } -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()?; - // In Edit Points, the edited shape's vertices come first. - if let Some(id) = self.tool.editing_points(st) - && let Some(sh) = st.shape(id) - { - if let Some((path, index)) = self.tool.edit_vertex - && let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) - { - for (h, out) in [(hout, true), (hin, false)] { - if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) { - return Some(Hit::Handle { id, path, index, out }); - } - } - } - if let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) { - return Some(Hit::Vertex { id, path, index }); - } - } - // Handles of selected shapes first. - if st.selection.text.is_none() || st.selection.shapes.len() == 1 { - for id in st.selection.shapes.iter().rev() { - let Some(sh) = st.shape(*id) else { continue }; - // A locked object shows no live handles: it can't be sized, rotated or reshaped. - if sh.locked { - 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 = deckcraft_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 }); - } - } - for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { - if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { - return Some(Hit::Warp { id: *id, index: hd.index }); - } - } - let (pts, rot) = box_handles(&x); - if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { - return Some(Hit::Rotate { id: *id }); - } - for (i, q) in pts.iter().enumerate() { - if (*q - p).hypot() <= tol * 1.6 { - return Some(Hit::Resize { id: *id, handle: i as u8 }); - } - } - } - } - // The frame of the text being edited: grabbing it moves the box, unless a shape in front - // of the text covers that spot (#931). - if let Some(e) = edited_box(st) - && e.movable - && edit_zone(&e.xfrm, p, tol) == EditZone::Frame - && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) - { - return Some(Hit::Frame { id: e.id }); - } - // Shapes, topmost first. - // Locked objects are still picked (to format them or type in them); they just don't move. - for sh in st.shapes().iter().rev() { - if sh.hidden { - 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); - self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); - if !(p.x.is_finite() && p.y.is_finite()) { - return Ok(Value::Null); - } - // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. - if !matches!(ev.kind, PointerKind::Move) { - self.settle_composition(); - } - match ev.kind { - PointerKind::Move => { - if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { - let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0); - let mut v = points.clone(); - v.push(p); - self.tool.path_preview = v; - self.tool.path_smooth = mode == "curve"; - self.tool.path_closing = closing; - return Ok(Value::Null); - } - self.tool.hover = self.hit_test(p, tol); - self.tool.guide_hover = - if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; - let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); - if line_tool { - self.update_sites(p, tol, None); - } else { - self.tool.sites.clear(); - self.tool.glue = None; - } - 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 => { - let before = self.text_selection(); - let r = self.double_click(p, tol)?; - self.paint_if_selection_changed(before, r) - } - PointerKind::ContextClick => self.context_click(p, tol), - PointerKind::TripleClick => { - if self.active().is_some_and(|d| d.selection.text.is_some()) { - let before = self.text_selection(); - let r = self.execute("text.selectParagraph", &json!({}))?; - self.paint_if_selection_changed(before, r) - } else { - Ok(Value::Null) - } - } - } - } - - /// Format Painter applies once a text range is selected (drag, word or paragraph click). - fn paint_text_selection(&mut self) -> Result> { - if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) { - return self.execute("format.painterApply", &json!({})).map(Some); - } - Ok(None) - } - - /// Where the text selection is: its shape, cell or notes, anchor and caret. - fn text_selection(&self) -> Option { - self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret)) - } - - /// A double or triple click paints only the range it selected, not an older one it left alone. - fn paint_if_selection_changed(&mut self, before: Option, r: Value) -> Result { - if self.text_selection() == before { - return Ok(r); - } - self.paint_text_selection().map(|a| a.unwrap_or(r)) - } - - pub(crate) fn begin(&mut self, label: &str) { - let vertex_kind = self.tool.vertex_kind.clone(); - 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(), vertex_kind }); - } - } - - 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, vertex_kind: i.vertex_kind }); - } - } - - /// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the - /// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled. - pub(crate) fn cancel_point_drag(&mut self) -> bool { - if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) { - return false; - } - self.tool.drag = None; - if let Some(st) = self.active_mut() - && st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points") - && let Some(i) = st.interaction.take() - { - // A modifier held during the drag may have set a point type; the drag is undone, so is that. - let kind = i.vertex_kind; - if !Arc::ptr_eq(&i.doc, &st.doc) { - st.doc = i.doc; - st.selection = i.selection; - st.revision += 1; - } - self.tool.vertex_kind = kind; - } - true - } - - /// End the interaction only when it is the one `label` started. A twist must not commit a drag. - pub(crate) fn commit_named(&mut self, label: &str) { - let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); - if ours { - self.commit(); - } - } - - fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { - if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { - // Next click of a freeform: close on the start point, else add a vertex. - if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) { - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return self.finish_freeform(true); - } - points.push(p); - self.tool.path_preview = points.clone(); - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); - return Ok(Value::Null); - } - match self.tool.kind.clone() { - ToolKind::Shape { preset } if is_freeform_tool(&preset) => { - self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true }); - self.tool.path_preview = vec![p]; - self.tool.path_smooth = preset == "curve"; - self.tool.path_closing = false; - return Ok(Value::Null); - } - ToolKind::Shape { preset } if preset == TABLE_ERASER => { - // Click a border to merge the cells on either side; elsewhere does nothing. - return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(Value::Null)); - } - ToolKind::Shape { .. } | ToolKind::TextBox => { - self.tool.drag = Some(Drag::Create { start: p }); - self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); - return Ok(Value::Null); - } - ToolKind::Ink { mode, .. } => { - if mode == "eraser" { - return self.erase_at(p, tol); - } - let sample = self.ink_sample(p); - self.tool.drag = Some(Drag::Ink { points: vec![sample] }); - self.tool.ink_preview = vec![sample]; - return Ok(Value::Null); - } - ToolKind::Select => {} - } - // Clicking inside the text being edited moves the caret / starts a text selection. - let before = self.text_selection(); - if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { - let press = self.doc()?.selection.text.clone(); - self.tool.drag = Some(Drag::TextSelect { shape, before, press }); - return Ok(json!({"caret": [pos.0, pos.1]})); - } - let hit = self.hit_test(p, tol); - // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; - // a click anywhere else leaves Edit Points. - if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { - if let Some(Hit::Vertex { id, path, index }) = hit { - if mods.cmd { - return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); - } - self.tool.edit_vertex = Some((path, index)); - self.begin("Edit Points"); - self.tool.drag = Some(Drag::Vertex { id, path, index }); - return Ok(json!({"vertex": [path, index]})); - } - if let Some(Hit::Handle { id, path, index, out }) = hit { - self.begin("Edit Points"); - self.tool.drag = Some(Drag::Handle { id, path, index, out }); - return Ok(json!({"handle": [path, index]})); - } - let st = self.doc()?; - if let Some(sh) = st.shape(eid) - && let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6) - { - if mods.cmd { - return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t})); - } - return Ok(Value::Null); - } - self.tool.edit_points = None; - self.tool.edit_vertex = None; - } - let st = self.doc()?; - match hit { - Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, - Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, - Some(Hit::Resize { id, handle }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - self.begin("Size"); - 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 }); - } - // Only reached outside Edit Points (handled above). - Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} - Some(Hit::Adjust { id, index }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - self.begin("Adjust Shape"); - self.tool.drag = Some(Drag::Adjust { id, index, orig }); - } - Some(Hit::Frame { id }) => { - // Like PowerPoint: the box leaves text editing and is selected whole (a click - // without a drag stops there), and dragging moves it as one undo step. - let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); - self.select(|_, sel| { - sel.shapes = vec![id]; - sel.text = None; - sel.cells = None; - })?; - self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); - } - Some(Hit::Warp { id, index }) => { - let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); - let found = st.shape(id).and_then(|sh| { - let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; - let bp = deckcraft_model::resolve::body(&ctx, sh); - bp.warp.map(|n| (n, bp.warp_adj)) - }); - if let Some((name, base)) = found { - self.begin("Adjust WordArt"); - self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); - } - } - Some(Hit::Shape { id, .. }) - if !(mods.shift || mods.cmd) - && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) - && in_table_cell(st, id, p) => - { - // A press in a table whose cells are selected starts again in the cell under the - // pointer: a click puts the caret there and a drag selects another range. A press - // on the table outside its cells selects and moves the table like any shape. - let before = self.text_selection(); - if self.edit_text_at(id, p)? { - self.select(|_, sel| sel.cells = None)?; - let press = self.doc()?.selection.text.clone(); - self.tool.drag = Some(Drag::TextSelect { shape: id, before, press }); - } - } - 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] - }; - // Locked objects stay put when the selection is dragged. - let origs: Vec<(ShapeId, Xfrm)> = - ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); - // A click (no drag) on text puts the caret there; unselected text boxes and - // placeholders take the caret straight away like PowerPoint. A click in a cell of a - // selected table puts the caret there; a click on its grab band keeps the table selected. - let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); - // A click on the frame band of a text box selects the box whole, as PowerPoint - // does, so it can be moved, duplicated or deleted; only the inside takes the caret. - let on_frame = text - && is_text_first(st, id) - && st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame); - let enter_text = - (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected && in_table_cell(st, id, p)))) - .then_some((id, p)); - let ids2 = ids.clone(); - self.select(|_, sel| { - sel.shapes = ids2; - sel.text = None; - sel.cells = None; - })?; - let duplicate = mods.alt && origs.len() == ids.len(); - self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text }); - // Format Painter applies on click. - if self.painter.is_some() && !toggle { - let _ = self.execute("format.painterApply", &json!({"ids": [id]})); - } - // Animation Painter applies on click. - if self.anim_painter.is_some() && !toggle { - let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); - } - } - None if self.guide_at(p, tol).is_some() => { - // Away from every shape a press on a guide picks the guide up. - if let Some(index) = self.guide_at(p, tol) { - let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); - self.tool.drag = Some(Drag::Guide { index }); - self.tool.guide_drag = Some((index, pos)); - } - } - None => { - let add = mods.shift || mods.cmd; - self.select(|_, sel| { - 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::Guide { index } => { - let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); - let pos = if vertical { p.x } else { p.y }; - // Guides move in whole points unless snapping is off, as in PowerPoint. - let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; - self.tool.guide_drag = Some((index, pos)); - } - Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { - let mut d = p - start; - if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { - 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) = deckcraft_model::find_shape_mut(list, *id) { - sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); - } - } - // A connector dragged away from shapes that stay put comes unglued at those ends. - let moved: Vec = origs2.iter().map(|o| o.0).collect(); - for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) { - if let ShapeKind::Connector { start, end } = &mut sh.kind { - for c in [start, end] { - if c.is_some_and(|(to, _)| !moved.contains(&to)) { - *c = None; - } - } - } - } - 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 glue = self.update_sites(q, tol, Some(id)); - if let Some((_, _, at)) = glue { - q = at; - } - let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) }; - self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?; - self.edit(|doc, sel| { - if let Some(list) = crate::shapes_mut(doc, sel) { - crate::connect::set_glue(list, id, end, glue.map(|g| (g.0, g.1))); - } - Ok(()) - })?; - } - 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 = deckcraft_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) = deckcraft_model::find_shape_mut(list, id) - && let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom - { - let defaults = deckcraft_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::Vertex { id, path, index } => { - self.tool.drag = Some(Drag::Vertex { id, path, index }); - self.edit_vertex(id, path, index, p)?; - return Ok(Value::Null); - } - Drag::Handle { id, path, index, out } => { - self.tool.drag = Some(Drag::Handle { id, path, index, out }); - let side = if out { "out" } else { "in" }; - // Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or - // corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt. - let held = if mods.shift { - Some("smooth") - } else if mods.cmd { - Some("straight") - } else if mods.alt { - Some("corner") - } else { - None - }; - if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) { - log::debug!("edit handle: {e}"); - } - return Ok(Value::Null); - } - Drag::Warp { id, index, name, base, orig } => { - let v = { - let st = self.doc()?; - let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; - let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; - let hs = deckcraft_geom::warp::handles(&name, &base, tr); - let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; - h.value_at(orig.affine().inverse() * p) - }; - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; - if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { - set_warp_adj(sh, &name, &base, index, v); - } - Ok(()) - })?; - } - Drag::Marquee { start, .. } => { - self.tool.marquee = Some(Rect::from_points(start, p)); - } - Drag::Create { start } => { - let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); - let x = if line { - let mut q = if mods.shift { constrain_45(start, p) } else { p }; - if let Some((_, _, at)) = self.update_sites(q, tol, None) { - q = at; - } - let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); - line_xfrm(s0, q, 0.0) - } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) - && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() - { - // The pencil inside a table draws a straight border, across or down. - let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; - let r = Rect::from_points(start, q); - Xfrm::new(r.x0, r.y0, r.width(), r.height()) - } else { - let mut r = Rect::from_points(start, p); - if mods.shift { - 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, press, .. } => { - // In a table, leaving the cell the drag started in selects whole cells, as in - // PowerPoint; coming back to that cell selects text in it again. - let st = self.doc()?; - if let Some(start) = press.as_ref().and_then(|t| t.cell) - && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) - && let Some(cell) = f.cell_at(f.local(p)) - { - if cell != start { - self.select(|_, sel| { - sel.text = None; - sel.shapes = vec![shape]; - sel.cells = Some((shape, start, cell)); - })?; - return Ok(Value::Null); - } - if st.selection.text.is_none() { - self.select(|_, sel| { - sel.cells = None; - sel.text = press; - })?; - } - } - let st = self.doc()?; - if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { - let pos = cmd::text::text_pos_at(st, t, p); - self.select(|_, sel| { - if let Some(t) = sel.text.as_mut() { - t.caret = pos; - } - })?; - } - } - Drag::TableBorder { id, column, index, start, size, frame, moved } => { - let local = frame.inverse() * p; - let d = if column { local.x - start.x } else { local.y - start.y }; - if !moved && d.abs() < tol.max(1.0) { - return Ok(Value::Null); - } - if !moved { - self.begin(if column { "Column Width" } else { "Row Height" }); - } - // An inner column border moves between its two columns (the table keeps its - // width); the right edge widens the last column and the table. A row's height is - // a minimum: rows still grow to fit their text. - if column { - let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); - self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; - } else { - self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; - } - self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); - } - Drag::Freeform { mode, mut points, pressed } => { - // Dragging scribbles (except for curves, which only take clicked points). - if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { - points.push(p); - } - let mut v = points.clone(); - if mode == "curve" { - v.push(p); - } - self.tool.path_preview = v; - self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); - } - Drag::Ink { mut points } => { - points.push(self.ink_sample(p)); - self.tool.ink_preview = points.clone(); - self.tool.drag = Some(Drag::Ink { points }); - } - } - Ok(Value::Null) - } - - /// Moves a vertex during a drag (one undo step for the whole drag). - fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { - let r = cmd::points::move_point_raw(self, id, path, index, p); - if let Err(e) = r { - log::debug!("edit point: {e}"); - } - Ok(()) - } - - /// Moves a control handle during a drag. `held` is the point type set by the modifier held - /// during the drag; the point keeps that type for later drags, as after `shape.pointType`. - fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result { - let kind = match held { - Some(k) => Some(k.to_string()), - None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3), - }; - let r = - cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?; - if let Some(k) = held { - self.tool.vertex_kind = Some((id, path, index, k.to_string())); - } - Ok(r) - } - - fn up(&mut self, p: Point, mods: Mods) -> Result { - let drag = self.tool.drag.take(); - self.tool.guides.clear(); - self.tool.sites.clear(); - self.tool.glue = None; - let mut out = Value::Null; - match drag { - Some(Drag::Guide { index }) => { - // Dropped off the slide: the guide is deleted. - let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); - let size = self.doc()?.doc.slide_size; - let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); - let limit = if vertical { size.width } else { size.height }; - out = if pos < 0.0 || pos > limit { - self.execute("guide.delete", &json!({"index": index}))? - } else { - self.execute("guide.move", &json!({"index": index, "pos": pos}))? - }; - } - Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => { - // Plain click on text: caret at the click (in a table, in the clicked cell). - if self.edit_text_at(id, at)? { - 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 } if preset == DRAW_TABLE => { - // Inside a table: split cells along the line. Elsewhere: a one-cell table. - if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() { - self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null) - } else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) { - self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))? - } else { - Value::Null - } - } - ToolKind::Shape { preset } => { - let line = deckcraft_geom::preset::is_line_like(preset); - let rect = match preview.filter(|_| dragged) { - 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 id = ShapeId(id as u32); - let _ = self.set_xfrm_cmd(id, x); - // Glue the ends that landed on connection sites. - let (p0, p1) = crate::connect::endpoints(&x); - let (g0, g1) = (self.glue_at(p0, 4.0), self.glue_at(p1, 4.0)); - if g0.is_some() || g1.is_some() { - let _ = self.edit(|doc, sel| { - if let Some(list) = crate::shapes_mut(doc, sel) { - crate::connect::set_glue(list, id, 0, g0.map(|g| (g.0, g.1))); - crate::connect::set_glue(list, id, 1, g1.map(|g| (g.0, g.1))); - } - Ok(()) - }); - } - } - 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, - }; - // The table pencil stays on until you pick another tool, as in PowerPoint. - if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) { - self.tool.kind = ToolKind::Select; - } - out = res; - } - Some(Drag::Freeform { mode, points, .. }) => { - if mode == "scribble" { - // A scribble ending back at its start closes into a filled shape. - let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0); - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return self.finish_freeform(closed); - } - // Polygon / curve: keep going until double-click, Enter or a click on the start. - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return Ok(Value::Null); - } - 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(Drag::TextSelect { before, .. }) => { - out = self.paint_if_selection_changed(before, out)?; - self.commit(); - } - 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 let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { - // The double-click's own click added a duplicate vertex. - while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) { - points.pop(); - } - self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); - return self.finish_freeform(false); - } - if !matches!(self.tool.kind, ToolKind::Select) { - // Double-click while drawing: lock drawing mode off. - self.tool.sticky = false; - return Ok(Value::Null); - } - // The double-click's second press started a click (caret on release): this replaces it. - if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { - self.tool.drag = None; - } - // In the text being edited: select the word double-clicked. - if self.place_caret(p, tol, false)?.is_some() { - return self.execute("text.selectWord", &json!({})); - } - match self.hit_test(p, tol) { - 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(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { - // Caret at the double-click (in a table, in the clicked cell), then select - // the word there. - if !self.edit_text_at(id, p)? { - return Ok(Value::Null); - } - self.execute("text.selectWord", &json!({})) - } - ShapeKind::Group { .. } => { - // A group is a single selection unit, but its text stays editable: - // double-click reaches the text-holding member under the cursor. - match text_member_at(st, &sh, p, tol).and_then(|mid| st.shape(mid).cloned().map(|m| (mid, m))) { - Some((mid, msh)) if !msh.is_line() => { - let pos = text_pos(st, &msh, p); - self.execute("text.edit", &json!({"id": mid, "at": [pos.0, pos.1]}))?; - self.execute("text.selectWord", &json!({})) - } - _ => Ok(Value::Null), - } - } - _ => { - self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); - Ok(Value::Null) - } - } - } - _ => Ok(Value::Null), - } - } - - /// Right-button press: the selection the context menu will act on, as in PowerPoint. In the - /// box being edited, the caret and text selection stay (the menu then acts on the text). On a - /// selected shape (or its handles) the selection stays, multiple selection included; on - /// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text, - /// starts a drag or adds an undo step. - fn context_click(&mut self, p: Point, tol: f64) -> Result { - if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() { - return Ok(Value::Null); - } - let st = self.doc()?; - // The same zone in which a left press places the caret (`down`). - if let Some(t) = &st.selection.text - && !t.notes - && t.cell.is_none() - && let Some(sh) = st.shape(t.shape) - && xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p) - { - return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); - } - let hit = self.hit_test(p, tol); - let id = match &hit { - Some( - Hit::Shape { id, .. } - | Hit::Resize { id, .. } - | Hit::Rotate { id } - | Hit::Adjust { id, .. } - | Hit::LineEnd { id, .. } - | Hit::Handle { id, .. } - | Hit::Vertex { id, .. } - | Hit::Frame { id } - | Hit::TableColumn { id, .. } - | Hit::TableRow { id, .. } - | Hit::Warp { id, .. }, - ) => Some(*id), - None => None, - }; - // Edit Points: the point or else the segment under the pointer picks the menu. - let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit { - Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None), - Some(Hit::Handle { .. }) => (None, None), - _ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)), - }); - if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { - self.select(|_, sel| { - sel.text = None; - sel.cells = None; - sel.shapes = id.into_iter().collect(); - })?; - } - if let Some((vertex, segment)) = picked { - // A control handle belongs to the picked point: its menu stays. - if !matches!(hit, Some(Hit::Handle { .. })) { - self.tool.edit_vertex = vertex; - } - self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg)); - self.tool.edit_segment_t = segment.map(|(_, _, t)| t); - } - let st = self.doc()?; - Ok(json!({ - "hit": serde_json::to_value(&hit).unwrap_or_default(), - "selected": st.selection.shapes, - "editing": st.selection.text.is_some(), - })) - } - - /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection - /// grows to it). In a table being edited, a click in another cell moves the caret into that - /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text - /// (or is on a handle or a table border). - fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { - let st = self.doc()?; - let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; - let Some(sh) = st.shape(t.shape) else { return Ok(None) }; - // A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`). - // A table cell: anywhere over the table. - let inside = match t.cell { - None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), - Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), - }; - // A shape in front of the edited text takes the click, as in PowerPoint (#931). - let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0); - if !inside - || in_front - || matches!( - self.hit_test(p, tol), - Some( - Hit::Resize { .. } - | Hit::Rotate { .. } - | Hit::Adjust { .. } - | Hit::Warp { .. } - | Hit::Frame { .. } - | Hit::TableColumn { .. } - | Hit::TableRow { .. } - ) - ) - { - return Ok(None); - } - let old_cell = t.cell; - if old_cell.is_some() - && let Some(f) = cmd::table::table_frame(st, sh) - { - t.cell = f.cell_at(f.local(p)).or(old_cell); - } - let pos = cmd::text::text_pos_at(st, &t, p); - // A selection can't span cells: a click in another cell starts afresh. - let extend = extend && t.cell == old_cell; - let cell = t.cell; - self.select(|_, sel| { - if let Some(tt) = sel.text.as_mut() { - tt.cell = cell; - tt.caret = pos; - if !extend { - tt.anchor = pos; - } - } - })?; - Ok(Some((t.shape, pos))) - } - - /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell - /// at `p`. Returns whether editing started. - fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { - let st = self.doc()?; - let Some(sh) = st.shape(id) else { return Ok(false) }; - let params = match cmd::table::table_frame(st, sh) { - Some(f) => { - let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; - let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); - json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) - } - None => { - let pos = text_pos(st, sh, p); - json!({"id": id, "at": [pos.0, pos.1]}) - } - }; - self.execute("text.edit", ¶ms)?; - Ok(true) - } - - /// Press on a table border: dragging it resizes the column or row. Editing a cell of the - /// table goes on; otherwise the table becomes the selection. - fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { - let st = self.doc()?; - let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; - let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); - let (start, frame) = (f.local(p), f.affine); - if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) { - self.select(|_, sel| { - sel.shapes = vec![id]; - sel.text = None; - sel.cells = None; - })?; - } - self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); - Ok(()) - } - - fn is_picture(&self, id: ShapeId) -> bool { - self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_))) - } - - /// End the freeform being drawn: create the shape (if it has at least two points). - pub fn finish_freeform(&mut self, closed: bool) -> Result { - let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) }; - self.tool.path_preview.clear(); - self.tool.path_closing = false; - if !self.tool.sticky { - self.tool.kind = ToolKind::Select; - } - if points.len() < 2 { - return Ok(Value::Null); - } - let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); - self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"})) - } - - /// The connection site within glue range of `p` (excluding shape `exclude`). - fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> { - let st = self.active()?; - crate::connect::nearest_site(&st.doc, &st.selection, st.shapes(), p, (tol * 2.0).max(6.0), None) - } - - /// Show the sites of the shape near `p` and return the site an end at `p` would glue to. - fn update_sites(&mut self, p: Point, tol: f64, exclude: Option) -> Option<(ShapeId, u32, Point)> { - let (sites, glue) = match self.active() { - Some(st) => { - let shapes = st.shapes(); - let near = crate::connect::site_shape_at(&st.doc, &st.selection, shapes, p, tol * 3.0, exclude); - let sites = near - .and_then(|id| shapes.iter().find(|s| s.id == id)) - .map(|s| crate::connect::sites(&st.doc, &st.selection, s)) - .unwrap_or_default(); - (sites, crate::connect::nearest_site(&st.doc, &st.selection, shapes, p, (tol * 2.0).max(6.0), exclude)) - } - None => (vec![], None), - }; - self.tool.sites = sites; - self.tool.glue = glue.map(|g| g.2); - glue - } - - fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> { - // Rows scale from their drawn heights: a stored height is only a minimum (often 0). - let drawn = self.active().and_then(|st| match &st.shape(id)?.kind { - ShapeKind::Table(t) => Some(table_rows(st, t)), - _ => None, - }); - self.edit(|doc, sel| { - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?; - if let Some(sh) = deckcraft_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 heights: Vec = match &drawn { - Some(d) if d.len() == t.rows.len() => d.clone(), - _ => t.rows.iter().map(|r| r.height).collect(), - }; - let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::()); - let kx = x.w / tw; - t.cols.iter_mut().for_each(|c| *c *= kx); - if th > 1e-6 { - let ky = x.h / th; - t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * 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| deckcraft_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("edit.duplicate", 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(()) - }) - } - - /// The drawing guide within `tol` of `p` (only while guides are shown). - pub fn guide_at(&self, p: Point, tol: f64) -> Option { - if !self.tool.guides_visible { - return None; - } - let st = self.active()?; - st.doc - .guides - .iter() - .enumerate() - .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) - .filter(|(_, d)| *d <= tol.max(2.0)) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(i, _)| i) - } - - /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to - /// apply and the guides to draw. - fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { - self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3]) - } - - /// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on` - /// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that - /// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped. - fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (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)), - ]; - if self.tool.guides_visible { - for g in &st.doc.guides { - if g.vertical { - xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); - } else { - ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); - } - } - } - for sh in st.shapes() { - if exclude.contains(&sh.id) || sh.hidden { - continue; - } - 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].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) { - 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].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) { - 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 && xs_on.contains(&true) { - let at = if xs_on[0] { - r.x0 - } else if xs_on[2] { - r.x1 - } else { - r.center().x - }; - off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; - } - 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 && ys_on.contains(&true) { - let at = if ys_on[0] { - r.y0 - } else if ys_on[2] { - r.y1 - } else { - r.center().y - }; - off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; - } - (off, guides) - } - - /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart 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(); - // Only the dragged edges move, so only they look for a line to snap to. The handles sit in - // the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge - // just flipped, the dragged handle is on the other side. - let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4)); - let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); - let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) }; - let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) }; - let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); - let mut nx = x; - 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 - } - - /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. - fn ink_sample(&self, p: Point) -> (f64, f64, f32) { - let pressure = match self.tool.pressure { - Some(v) if v.is_finite() => v.clamp(0.0, 1.0), - _ => 1.0, - }; - (p.x, p.y, pressure) - } - - fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { - let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; - let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; - // 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().ok_or_else(|| cmd::bad("ink.draw", cmd::NO_ID_SPACE))?); - let size = doc.slide_size; - let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; - list.push(Shape { - 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) = deckcraft_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) = deckcraft_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(()) - })?; - let vertex_kind = self.tool.vertex_kind.clone(); - if let Some(st) = self.active_mut() { - crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind }); - } - 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) -} - -/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. -/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is -/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing. -fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { - let ShapeKind::Group { children, child } = &sh.kind else { return None }; - let x = xfrm_of(&st.doc, &st.selection, sh); - let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); - member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol) -} - -/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`. -/// `to_abs` maps this level's child space to slide space. -fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option { - let q = to_abs.inverse() * p; - for c in children.iter().rev() { - let Some(cx) = c.xfrm else { continue }; - let local = cx.affine().inverse() * q; - if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol { - continue; - } - if let ShapeKind::Group { children: gc, child: gch } = &c.kind { - let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h); - let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch); - if let Some(id) = member_at(gc, nested, p, tol) { - return Some(id); - } - } else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() { - return Some(c.id); - } - } - None -} - -/// The box whose text is being edited on the slide (not speaker notes, not a table cell). -struct EditedBox { - id: ShapeId, - xfrm: Xfrm, - /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. - movable: bool, -} - -/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its -/// list, topmost first, and inside a group that holds it, the members above it. -fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool { - if depth > 64 { - return false; - } - for s in shapes.iter().rev() { - if s.id == target { - return false; - } - if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() { - // The group holding the text: look among its members. - return shape_in_front(st, s.children(), target, p, tol, depth + 1); - } - if !s.hidden && hit_shape(st, s, p, tol).is_some() { - return true; - } - } - false -} - -fn edited_box(st: &crate::DocState) -> Option { - let t = st.selection.text.as_ref()?; - if t.notes || t.cell.is_some() { - return None; - } - let sh = st.shape(t.shape)?; - Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked }) -} - -/// Where a point falls against the box of the text being edited. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum EditZone { - /// Well inside: the caret goes there. - Inside, - /// On the frame: a band `tol` wide on either side of the box edge. - Frame, - Outside, -} - -/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside -/// small boxes the band narrows to a quarter of the box so their text stays reachable. -fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { - let l = x.affine().inverse() * p; - let (w, h) = (x.w.max(0.0), x.h.max(0.0)); - let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; - if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { - return EditZone::Outside; - } - let inner = tol.min(w / 4.0).min(h / 4.0); - if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } -} - -fn contains_rect(outer: Rect, inner: Rect) -> bool { - inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 -} - -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 } -} - -/// Whether `p` lies in a cell of table `id`, not in the grab band around it (`cell_at` would take -/// the nearest cell). -fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool { - st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)).is_some_and(|f| { - let q = f.local(p); - (0.0..=f.width()).contains(&q.x) && (0.0..=f.height()).contains(&q.y) && f.cell_at(q).is_some() - }) -} - -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; - // Table borders resize rows and columns. - let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; - if near && let Some(f) = cmd::table::table_frame(st, sh) { - match f.border_at(f.local(p), tol) { - Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), - Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), - None => {} - } - } - match &sh.kind { - ShapeKind::Group { children, child } => { - // A group is hit when any child is hit; the group is what gets selected. - let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); - let m = deckcraft_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 = deckcraft_render::shape_geometry(sh, x.w, x.h); - let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0); - if deckcraft_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(deckcraft_model::Fill::None)) || sh.style.is_some(); - let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); - let in_geo = { - use deckcraft_geom::KurboShape; - geo.outline().winding(local) != 0 || deckcraft_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::Model3d { .. } - | ShapeKind::Opaque { .. } - ) - || (filled && in_geo) - || deckcraft_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) { - cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) -} - -/// Row heights of table `t` as drawn: rows grow to fit their text. -pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec { - cmd::table_drawn_heights(&st.doc, &st.selection, t) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/tools/click.rs b/crates/engine/src/tools/click.rs new file mode 100644 index 000000000..802eb8c5e --- /dev/null +++ b/crates/engine/src/tools/click.rs @@ -0,0 +1,219 @@ +use super::hit::{EditZone, edit_zone, edited_box, shape_in_front, text_member_at, text_pos}; +use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, ToolKind, Value, cmd, json, xfrm_of}; + +impl Session { + pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result { + if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { + // The double-click's own click added a duplicate vertex. + while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) { + points.pop(); + } + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(false); + } + if !matches!(self.tool.kind, ToolKind::Select) { + // Double-click while drawing: lock drawing mode off. + self.tool.sticky = false; + return Ok(Value::Null); + } + // The double-click's second press started a click (caret on release): this replaces it. + if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) { + self.tool.drag = None; + } + // In the text being edited: select the word double-clicked. + if self.place_caret(p, tol, false)?.is_some() { + return self.execute("text.selectWord", &json!({})); + } + match self.hit_test(p, tol) { + 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(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => { + // Caret at the double-click (in a table, in the clicked cell), then select + // the word there. + if !self.edit_text_at(id, p)? { + return Ok(Value::Null); + } + self.execute("text.selectWord", &json!({})) + } + ShapeKind::Group { .. } => { + // A group is a single selection unit, but its text stays editable: + // double-click reaches the text-holding member under the cursor. + match text_member_at(st, &sh, p, tol).and_then(|mid| st.shape(mid).cloned().map(|m| (mid, m))) { + Some((mid, msh)) if !msh.is_line() => { + let pos = text_pos(st, &msh, p); + self.execute("text.edit", &json!({"id": mid, "at": [pos.0, pos.1]}))?; + self.execute("text.selectWord", &json!({})) + } + _ => Ok(Value::Null), + } + } + _ => { + self.ui_requests.push(crate::UiRequest::Pane { id: "format".into() }); + Ok(Value::Null) + } + } + } + _ => Ok(Value::Null), + } + } + + /// Right-button press: the selection the context menu will act on, as in PowerPoint. In the + /// box being edited, the caret and text selection stay (the menu then acts on the text). On a + /// selected shape (or its handles) the selection stays, multiple selection included; on + /// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text, + /// starts a drag or adds an undo step. + pub(super) fn context_click(&mut self, p: Point, tol: f64) -> Result { + if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() { + return Ok(Value::Null); + } + let st = self.doc()?; + // The same zone in which a left press places the caret (`down`). + if let Some(t) = &st.selection.text + && !t.notes + && t.cell.is_none() + && let Some(sh) = st.shape(t.shape) + && xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p) + { + return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true})); + } + let hit = self.hit_test(p, tol); + let id = match &hit { + Some( + Hit::Shape { id, .. } + | Hit::Resize { id, .. } + | Hit::Rotate { id } + | Hit::Adjust { id, .. } + | Hit::LineEnd { id, .. } + | Hit::Handle { id, .. } + | Hit::Vertex { id, .. } + | Hit::Frame { id } + | Hit::TableColumn { id, .. } + | Hit::TableRow { id, .. } + | Hit::Warp { id, .. }, + ) => Some(*id), + None => None, + }; + // Edit Points: the point or else the segment under the pointer picks the menu. + let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit { + Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None), + Some(Hit::Handle { .. }) => (None, None), + _ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)), + }); + if !id.is_some_and(|id| st.selection.shapes.contains(&id)) { + self.select(|_, sel| { + sel.text = None; + sel.cells = None; + sel.shapes = id.into_iter().collect(); + })?; + } + if let Some((vertex, segment)) = picked { + // A control handle belongs to the picked point: its menu stays. + if !matches!(hit, Some(Hit::Handle { .. })) { + self.tool.edit_vertex = vertex; + } + self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg)); + self.tool.edit_segment_t = segment.map(|(_, _, t)| t); + } + let st = self.doc()?; + Ok(json!({ + "hit": serde_json::to_value(&hit).unwrap_or_default(), + "selected": st.selection.shapes, + "editing": st.selection.text.is_some(), + })) + } + + /// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection + /// grows to it). In a table being edited, a click in another cell moves the caret into that + /// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text + /// (or is on a handle or a table border). + pub(super) fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result> { + let st = self.doc()?; + let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) }; + let Some(sh) = st.shape(t.shape) else { return Ok(None) }; + // A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`). + // A table cell: anywhere over the table. + let inside = match t.cell { + None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside), + Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p), + }; + // A shape in front of the edited text takes the click, as in PowerPoint (#931). + let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0); + if !inside + || in_front + || matches!( + self.hit_test(p, tol), + Some( + Hit::Resize { .. } + | Hit::Rotate { .. } + | Hit::Adjust { .. } + | Hit::Warp { .. } + | Hit::Frame { .. } + | Hit::TableColumn { .. } + | Hit::TableRow { .. } + ) + ) + { + return Ok(None); + } + let old_cell = t.cell; + if old_cell.is_some() + && let Some(f) = cmd::table::table_frame(st, sh) + { + t.cell = f.cell_at(f.local(p)).or(old_cell); + } + let pos = cmd::text::text_pos_at(st, &t, p); + // A selection can't span cells: a click in another cell starts afresh. + let extend = extend && t.cell == old_cell; + let cell = t.cell; + self.select(|_, sel| { + if let Some(tt) = sel.text.as_mut() { + tt.cell = cell; + tt.caret = pos; + if !extend { + tt.anchor = pos; + } + } + })?; + Ok(Some((t.shape, pos))) + } + + /// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell + /// at `p`. Returns whether editing started. + pub(super) fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(false) }; + let params = match cmd::table::table_frame(st, sh) { + Some(f) => { + let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) }; + let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p); + json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]}) + } + None => { + let pos = text_pos(st, sh, p); + json!({"id": id, "at": [pos.0, pos.1]}) + } + }; + self.execute("text.edit", ¶ms)?; + Ok(true) + } + + /// Press on a table border: dragging it resizes the column or row. Editing a cell of the + /// table goes on; otherwise the table becomes the selection. + pub(super) fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> { + let st = self.doc()?; + let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) }; + let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0); + let (start, frame) = (f.local(p), f.affine); + if st.selection.text.as_ref().is_none_or(|t| t.shape != id) { + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false }); + Ok(()) + } +} diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs new file mode 100644 index 000000000..21aac4f38 --- /dev/null +++ b/crates/engine/src/tools/drag.rs @@ -0,0 +1,343 @@ +use super::hit::{constrain_45, line_xfrm, resize_box}; +use super::{ + Affine, DRAW_TABLE, Drag, Mods, Point, Rect, Result, Session, ShapeId, ShapeKind, TextSel, ToolKind, Value, Vec2, Xfrm, cmd, json, set_warp_adj, +}; + +struct MoveDrag { + ids: Vec, + start: Point, + origs: Vec<(ShapeId, Xfrm)>, + moved: bool, + duplicate: bool, + enter_text: Option<(ShapeId, Point)>, +} + +impl Session { + pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) }; + match drag { + Drag::Guide { index } => { + let vertical = self.active().and_then(|d| d.doc.guides.get(index)).is_some_and(|g| g.vertical); + let pos = if vertical { p.x } else { p.y }; + // Guides move in whole points unless snapping is off, as in PowerPoint. + let pos = if self.prefs.snap_to_grid && !mods.alt { pos.round() } else { pos }; + self.tool.guide_drag = Some((index, pos)); + } + Drag::Move { ids, start, origs, moved, duplicate, enter_text } => { + return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol); + } + 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 glue = self.update_sites(q, tol, Some(id)); + if let Some((_, _, at)) = glue { + q = at; + } + let (s0, s1) = if end == 0 { (q, p1) } else { (p0, q) }; + self.set_xfrm(id, line_xfrm(s0, s1, orig.rot))?; + self.edit(|doc, sel| { + if let Some(list) = crate::shapes_mut(doc, sel) { + crate::connect::set_glue(list, id, end, glue.map(|g| (g.0, g.1))); + } + Ok(()) + })?; + } + 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 } => return self.drag_adjust(id, index, orig, p), + Drag::Vertex { id, path, index } => { + self.tool.drag = Some(Drag::Vertex { id, path, index }); + self.edit_vertex(id, path, index, p)?; + return Ok(Value::Null); + } + Drag::Handle { id, path, index, out } => { + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + let side = if out { "out" } else { "in" }; + // Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or + // corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt. + let held = if mods.shift { + Some("smooth") + } else if mods.cmd { + Some("straight") + } else if mods.alt { + Some("corner") + } else { + None + }; + if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) { + log::debug!("edit handle: {e}"); + } + return Ok(Value::Null); + } + Drag::Warp { id, index, name, base, orig } => { + let v = { + let st = self.doc()?; + let Some(sh) = st.shape(id) else { return Ok(Value::Null) }; + let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect; + let hs = deckcraft_geom::warp::handles(&name, &base, tr); + let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) }; + h.value_at(orig.affine().inverse() * p) + }; + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?; + if let Some(sh) = deckcraft_model::find_shape_mut(list, id) { + set_warp_adj(sh, &name, &base, index, v); + } + Ok(()) + })?; + } + Drag::Marquee { start, .. } => { + self.tool.marquee = Some(Rect::from_points(start, p)); + } + Drag::Create { start } => self.drag_create(start, p, mods, tol), + Drag::TextSelect { shape, press, .. } => return self.drag_text_select(shape, press, p), + Drag::TableBorder { id, column, index, start, size, frame, moved } => { + let local = frame.inverse() * p; + let d = if column { local.x - start.x } else { local.y - start.y }; + if !moved && d.abs() < tol.max(1.0) { + return Ok(Value::Null); + } + if !moved { + self.begin(if column { "Column Width" } else { "Row Height" }); + } + // An inner column border moves between its two columns (the table keeps its + // width); the right edge widens the last column and the table. A row's height is + // a minimum: rows still grow to fit their text. + if column { + let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH); + self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?; + } else { + self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?; + } + self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true }); + } + Drag::Freeform { mode, mut points, pressed } => { + // Dragging scribbles (except for curves, which only take clicked points). + if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) { + points.push(p); + } + let mut v = points.clone(); + if mode == "curve" { + v.push(p); + } + self.tool.path_preview = v; + self.tool.drag = Some(Drag::Freeform { mode, points, pressed }); + } + Drag::Ink { mut points } => { + points.push(self.ink_sample(p)); + self.tool.ink_preview = points.clone(); + self.tool.drag = Some(Drag::Ink { points }); + } + } + Ok(Value::Null) + } + + fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result { + let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag; + let mut d = p - start; + if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) { + 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) = deckcraft_model::find_shape_mut(list, *id) { + sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o }); + } + } + // A connector dragged away from shapes that stay put comes unglued at those ends. + let moved: Vec = origs2.iter().map(|o| o.0).collect(); + for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) { + if let ShapeKind::Connector { start, end } = &mut sh.kind { + for c in [start, end] { + if c.is_some_and(|(to, _)| !moved.contains(&to)) { + *c = None; + } + } + } + } + Ok(()) + })?; + self.tool.drag = + Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) }); + Ok(Value::Null) + } + + fn drag_adjust(&mut self, id: ShapeId, index: usize, orig: Xfrm, p: Point) -> Result { + let st = self.doc()?; + let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) }; + let local = orig.affine().inverse() * p; + let geo = deckcraft_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) = deckcraft_model::find_shape_mut(list, id) + && let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom + { + let defaults = deckcraft_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(()) + })?; + Ok(Value::Null) + } + + fn drag_create(&mut self, start: Point, p: Point, mods: Mods, tol: f64) { + let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); + let x = if line { + let mut q = if mods.shift { constrain_45(start, p) } else { p }; + if let Some((_, _, at)) = self.update_sites(q, tol, None) { + q = at; + } + let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); + line_xfrm(s0, q, 0.0) + } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) + && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() + { + // The pencil inside a table draws a straight border, across or down. + let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; + let r = Rect::from_points(start, q); + Xfrm::new(r.x0, r.y0, r.width(), r.height()) + } else { + let mut r = Rect::from_points(start, p); + if mods.shift { + 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); + } + + fn drag_text_select(&mut self, shape: ShapeId, press: Option, p: Point) -> Result { + // In a table, leaving the cell the drag started in selects whole cells, as in + // PowerPoint; coming back to that cell selects text in it again. + let st = self.doc()?; + if let Some(start) = press.as_ref().and_then(|t| t.cell) + && let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh)) + && let Some(cell) = f.cell_at(f.local(p)) + { + if cell != start { + self.select(|_, sel| { + sel.text = None; + sel.shapes = vec![shape]; + sel.cells = Some((shape, start, cell)); + })?; + return Ok(Value::Null); + } + if st.selection.text.is_none() { + self.select(|_, sel| { + sel.cells = None; + sel.text = press; + })?; + } + } + let st = self.doc()?; + if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) { + let pos = cmd::text::text_pos_at(st, t, p); + self.select(|_, sel| { + if let Some(t) = sel.text.as_mut() { + t.caret = pos; + } + })?; + } + Ok(Value::Null) + } + + /// Moves a vertex during a drag (one undo step for the whole drag). + fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> { + let r = cmd::points::move_point_raw(self, id, path, index, p); + if let Err(e) = r { + log::debug!("edit point: {e}"); + } + Ok(()) + } + + /// Moves a control handle during a drag. `held` is the point type set by the modifier held + /// during the drag; the point keeps that type for later drags, as after `shape.pointType`. + fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result { + let kind = match held { + Some(k) => Some(k.to_string()), + None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3), + }; + let r = + cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?; + if let Some(k) = held { + self.tool.vertex_kind = Some((id, path, index, k.to_string())); + } + Ok(r) + } +} diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs new file mode 100644 index 000000000..536a4ead2 --- /dev/null +++ b/crates/engine/src/tools/geometry.rs @@ -0,0 +1,325 @@ +use super::hit::table_rows; +use super::{Drag, Guide, HistoryEntry, Point, Rect, Result, Session, Shape, ShapeId, ShapeKind, ToolKind, Value, Vec2, Xfrm, cmd, json, xfrm_of}; + +impl Session { + /// End the freeform being drawn: create the shape (if it has at least two points). + pub fn finish_freeform(&mut self, closed: bool) -> Result { + let Some(Drag::Freeform { mode, points, .. }) = self.tool.drag.take() else { return Ok(Value::Null) }; + self.tool.path_preview.clear(); + self.tool.path_closing = false; + if !self.tool.sticky { + self.tool.kind = ToolKind::Select; + } + if points.len() < 2 { + return Ok(Value::Null); + } + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + self.execute("shape.freeform", &json!({"points": pts, "closed": closed, "smooth": mode == "curve"})) + } + + /// The connection site within glue range of `p` (excluding shape `exclude`). + pub(super) fn glue_at(&self, p: Point, tol: f64) -> Option<(ShapeId, u32, Point)> { + let st = self.active()?; + crate::connect::nearest_site(&st.doc, &st.selection, st.shapes(), p, (tol * 2.0).max(6.0), None) + } + + /// Show the sites of the shape near `p` and return the site an end at `p` would glue to. + pub(super) fn update_sites(&mut self, p: Point, tol: f64, exclude: Option) -> Option<(ShapeId, u32, Point)> { + let (sites, glue) = match self.active() { + Some(st) => { + let shapes = st.shapes(); + let near = crate::connect::site_shape_at(&st.doc, &st.selection, shapes, p, tol * 3.0, exclude); + let sites = near + .and_then(|id| shapes.iter().find(|s| s.id == id)) + .map(|s| crate::connect::sites(&st.doc, &st.selection, s)) + .unwrap_or_default(); + (sites, crate::connect::nearest_site(&st.doc, &st.selection, shapes, p, (tol * 2.0).max(6.0), exclude)) + } + None => (vec![], None), + }; + self.tool.sites = sites; + self.tool.glue = glue.map(|g| g.2); + glue + } + + pub(super) fn set_xfrm(&mut self, id: ShapeId, x: Xfrm) -> Result<()> { + // Rows scale from their drawn heights: a stored height is only a minimum (often 0). + let drawn = self.active().and_then(|st| match &st.shape(id)?.kind { + ShapeKind::Table(t) => Some(table_rows(st, t)), + _ => None, + }); + self.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("tool", "no slide"))?; + if let Some(sh) = deckcraft_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 heights: Vec = match &drawn { + Some(d) if d.len() == t.rows.len() => d.clone(), + _ => t.rows.iter().map(|r| r.height).collect(), + }; + let (tw, th) = (t.width().max(1e-6), heights.iter().map(|h| h.max(0.0)).sum::()); + let kx = x.w / tw; + t.cols.iter_mut().for_each(|c| *c *= kx); + if th > 1e-6 { + let ky = x.h / th; + t.rows.iter_mut().zip(&heights).for_each(|(r, h)| r.height = h.max(0.0) * ky); + } + } + } + Ok(()) + }) + } + + pub(super) 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| deckcraft_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) + } + + pub(super) 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(()) + } + + pub(super) 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("edit.duplicate", 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(()) + }) + } + + /// The drawing guide within `tol` of `p` (only while guides are shown). + pub fn guide_at(&self, p: Point, tol: f64) -> Option { + if !self.tool.guides_visible { + return None; + } + let st = self.active()?; + st.doc + .guides + .iter() + .enumerate() + .map(|(i, g)| (i, if g.vertical { (p.x - g.pos).abs() } else { (p.y - g.pos).abs() })) + .filter(|(_, d)| *d <= tol.max(2.0)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + + /// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to + /// apply and the guides to draw. + pub(super) fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec) { + self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3]) + } + + /// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on` + /// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that + /// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped. + fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (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)), + ]; + if self.tool.guides_visible { + for g in &st.doc.guides { + if g.vertical { + xs.push((g.pos, Rect::new(g.pos, 0.0, g.pos, size.height))); + } else { + ys.push((g.pos, Rect::new(0.0, g.pos, size.width, g.pos))); + } + } + } + for sh in st.shapes() { + if exclude.contains(&sh.id) || sh.hidden { + continue; + } + 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].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) { + 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].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) { + 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 && xs_on.contains(&true) { + let at = if xs_on[0] { + r.x0 + } else if xs_on[2] { + r.x1 + } else { + r.center().x + }; + off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; + } + 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 && ys_on.contains(&true) { + let at = if ys_on[0] { + r.y0 + } else if ys_on[2] { + r.y1 + } else { + r.center().y + }; + off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at; + } + (off, guides) + } + + /// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides. + pub(super) 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(); + // Only the dragged edges move, so only they look for a line to snap to. The handles sit in + // the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge + // just flipped, the dragged handle is on the other side. + let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4)); + let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6)); + let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) }; + let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) }; + let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]); + let mut nx = x; + 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 + } + + /// One ink sample. No device reading is full pen width (`1`), not the old placeholder `0.5`. + pub(super) fn ink_sample(&self, p: Point) -> (f64, f64, f32) { + let pressure = match self.tool.pressure { + Some(v) if v.is_finite() => v.clamp(0.0, 1.0), + _ => 1.0, + }; + (p.x, p.y, pressure) + } + + pub(super) fn add_ink(&mut self, points: Vec<(f64, f64, f32)>) -> Result { + let ToolKind::Ink { mode, color, width } = self.tool.kind.clone() else { return Ok(Value::Null) }; + let stroke = deckcraft_model::InkStroke { points, color, width, highlighter: mode == "highlighter" }; + // 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().ok_or_else(|| cmd::bad("ink.draw", cmd::NO_ID_SPACE))?); + let size = doc.slide_size; + let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("ink", "no slide"))?; + list.push(Shape { + 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) = deckcraft_model::find_shape_mut(list, id) + && let ShapeKind::Ink { strokes } = &mut sh.kind + { + strokes.push(stroke); + } + Ok(json!({"ink": id})) + }) + } + + pub(super) 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) = deckcraft_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(()) + })?; + let vertex_kind = self.tool.vertex_kind.clone(); + if let Some(st) = self.active_mut() { + crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind }); + } + Ok(Value::Null) + } +} diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs new file mode 100644 index 000000000..6a0da529f --- /dev/null +++ b/crates/engine/src/tools/hit.rs @@ -0,0 +1,306 @@ +use super::{Affine, Hit, Point, Rect, Shape, ShapeId, ShapeKind, TextSel, Vec2, Xfrm, cmd, xfrm_of}; + +pub(super) fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool { + st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) +} + +/// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. +/// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is +/// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing. +pub(super) fn text_member_at(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option { + let ShapeKind::Group { children, child } = &sh.kind else { return None }; + let x = xfrm_of(&st.doc, &st.selection, sh); + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + member_at(children, deckcraft_geom::group_child_affine(&x, ch), p, tol) +} + +/// Depth-first (topmost-first) search of `children` for a text-holding shape under `p`. +/// `to_abs` maps this level's child space to slide space. +fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option { + let q = to_abs.inverse() * p; + for c in children.iter().rev() { + let Some(cx) = c.xfrm else { continue }; + let local = cx.affine().inverse() * q; + if local.x < -tol || local.y < -tol || local.x > cx.w + tol || local.y > cx.h + tol { + continue; + } + if let ShapeKind::Group { children: gc, child: gch } = &c.kind { + let gch = Rect::new(gch.x, gch.y, gch.x + gch.w, gch.y + gch.h); + let nested = to_abs * deckcraft_geom::group_child_affine(&cx, gch); + if let Some(id) = member_at(gc, nested, p, tol) { + return Some(id); + } + } else if matches!(c.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) && !c.is_line() { + return Some(c.id); + } + } + None +} + +/// The box whose text is being edited on the slide (not speaker notes, not a table cell). +pub(super) struct EditedBox { + pub(super) id: ShapeId, + pub(super) xfrm: Xfrm, + /// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked. + pub(super) movable: bool, +} + +/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its +/// list, topmost first, and inside a group that holds it, the members above it. +pub(super) fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool { + if depth > 64 { + return false; + } + for s in shapes.iter().rev() { + if s.id == target { + return false; + } + if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() { + // The group holding the text: look among its members. + return shape_in_front(st, s.children(), target, p, tol, depth + 1); + } + if !s.hidden && hit_shape(st, s, p, tol).is_some() { + return true; + } + } + false +} + +pub(super) fn edited_box(st: &crate::DocState) -> Option { + let t = st.selection.text.as_ref()?; + if t.notes || t.cell.is_some() { + return None; + } + let sh = st.shape(t.shape)?; + Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked }) +} + +/// Where a point falls against the box of the text being edited. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum EditZone { + /// Well inside: the caret goes there. + Inside, + /// On the frame: a band `tol` wide on either side of the box edge. + Frame, + Outside, +} + +/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside +/// small boxes the band narrows to a quarter of the box so their text stays reachable. +pub(super) fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone { + let l = x.affine().inverse() * p; + let (w, h) = (x.w.max(0.0), x.h.max(0.0)); + let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 }; + if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol { + return EditZone::Outside; + } + let inner = tol.min(w / 4.0).min(h / 4.0); + if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame } +} + +pub(super) fn contains_rect(outer: Rect, inner: Rect) -> bool { + inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1 +} + +pub(super) 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 } +} + +/// Whether `p` lies in a cell of table `id`, not in the grab band around it (`cell_at` would take +/// the nearest cell). +pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool { + st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)).is_some_and(|f| { + let q = f.local(p); + (0.0..=f.width()).contains(&q.x) && (0.0..=f.height()).contains(&q.y) && f.cell_at(q).is_some() + }) +} + +pub(super) 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; + // Table borders resize rows and columns. + let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol; + if near && let Some(f) = cmd::table::table_frame(st, sh) { + match f.border_at(f.local(p), tol) { + Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }), + Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }), + None => {} + } + } + match &sh.kind { + ShapeKind::Group { children, child } => { + // A group is hit when any child is hit; the group is what gets selected. + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let m = deckcraft_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 = deckcraft_render::shape_geometry(sh, x.w, x.h); + let lw = sh.line.as_ref().and_then(|l| l.width).unwrap_or(1.0); + if deckcraft_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(deckcraft_model::Fill::None)) || sh.style.is_some(); + let geo = deckcraft_render::shape_geometry(sh, x.w, x.h); + let in_geo = { + use deckcraft_geom::KurboShape; + geo.outline().winding(local) != 0 || deckcraft_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::Model3d { .. } + | ShapeKind::Opaque { .. } + ) + || (filled && in_geo) + || deckcraft_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) { + cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p) +} + +/// Row heights of table `t` as drawn: rows grow to fit their text. +pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec { + cmd::table_drawn_heights(&st.doc, &st.selection, t) +} diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs new file mode 100644 index 000000000..cb71cfed0 --- /dev/null +++ b/crates/engine/src/tools/pointer.rs @@ -0,0 +1,582 @@ +use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, shape_in_front}; +use super::{ + Arc, DRAW_TABLE, Drag, HistoryEntry, Hit, Mods, Point, PointerEvent, PointerKind, Rect, Result, Session, ShapeId, ShapeKind, TABLE_ERASER, + TextTarget, ToolKind, Value, Xfrm, box_handles, cmd, is_freeform_tool, json, wordart_handles, xfrm_of, +}; + +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()?; + // In Edit Points, the edited shape's vertices come first. + if let Some(id) = self.tool.editing_points(st) + && let Some(sh) = st.shape(id) + { + if let Some((path, index)) = self.tool.edit_vertex + && let Some((hin, hout, _)) = cmd::points::slide_handles(&st.doc, &st.selection, sh, path, index) + { + for (h, out) in [(hout, true), (hin, false)] { + if h.is_some_and(|q| (q - p).hypot() <= tol * 1.6) { + return Some(Hit::Handle { id, path, index, out }); + } + } + } + if let Some((path, index)) = cmd::points::vertex_at(&st.doc, &st.selection, sh, p, tol * 1.6) { + return Some(Hit::Vertex { id, path, index }); + } + } + // Handles of selected shapes first. + if st.selection.text.is_none() || st.selection.shapes.len() == 1 { + for id in st.selection.shapes.iter().rev() { + let Some(sh) = st.shape(*id) else { continue }; + // A locked object shows no live handles: it can't be sized, rotated or reshaped. + if sh.locked { + 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 = deckcraft_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 }); + } + } + for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) { + if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 { + return Some(Hit::Warp { id: *id, index: hd.index }); + } + } + let (pts, rot) = box_handles(&x); + if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() { + return Some(Hit::Rotate { id: *id }); + } + for (i, q) in pts.iter().enumerate() { + if (*q - p).hypot() <= tol * 1.6 { + return Some(Hit::Resize { id: *id, handle: i as u8 }); + } + } + } + } + // The frame of the text being edited: grabbing it moves the box, unless a shape in front + // of the text covers that spot (#931). + if let Some(e) = edited_box(st) + && e.movable + && edit_zone(&e.xfrm, p, tol) == EditZone::Frame + && !shape_in_front(st, st.shapes(), e.id, p, tol, 0) + { + return Some(Hit::Frame { id: e.id }); + } + // Shapes, topmost first. + // Locked objects are still picked (to format them or type in them); they just don't move. + for sh in st.shapes().iter().rev() { + if sh.hidden { + 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); + self.tool.pressure = ev.pressure.filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + if !(p.x.is_finite() && p.y.is_finite()) { + return Ok(Value::Null); + } + // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. + if !matches!(ev.kind, PointerKind::Move) { + self.settle_composition(); + } + match ev.kind { + PointerKind::Move => { + if let Some(Drag::Freeform { points, mode, .. }) = &self.tool.drag { + let closing = points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0); + let mut v = points.clone(); + v.push(p); + self.tool.path_preview = v; + self.tool.path_smooth = mode == "curve"; + self.tool.path_closing = closing; + return Ok(Value::Null); + } + self.tool.hover = self.hit_test(p, tol); + self.tool.guide_hover = + if self.tool.hover.is_none() && matches!(self.tool.kind, ToolKind::Select) { self.guide_at(p, tol) } else { None }; + let line_tool = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset)); + if line_tool { + self.update_sites(p, tol, None); + } else { + self.tool.sites.clear(); + self.tool.glue = None; + } + 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 => { + let before = self.text_selection(); + let r = self.double_click(p, tol)?; + self.paint_if_selection_changed(before, r) + } + PointerKind::ContextClick => self.context_click(p, tol), + PointerKind::TripleClick => { + if self.active().is_some_and(|d| d.selection.text.is_some()) { + let before = self.text_selection(); + let r = self.execute("text.selectParagraph", &json!({}))?; + self.paint_if_selection_changed(before, r) + } else { + Ok(Value::Null) + } + } + } + } + + /// Format Painter applies once a text range is selected (drag, word or paragraph click). + fn paint_text_selection(&mut self) -> Result> { + if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) { + return self.execute("format.painterApply", &json!({})).map(Some); + } + Ok(None) + } + + /// Where the text selection is: its shape, cell or notes, anchor and caret. + fn text_selection(&self) -> Option { + self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret)) + } + + /// A double or triple click paints only the range it selected, not an older one it left alone. + fn paint_if_selection_changed(&mut self, before: Option, r: Value) -> Result { + if self.text_selection() == before { + return Ok(r); + } + self.paint_text_selection().map(|a| a.unwrap_or(r)) + } + + pub(crate) fn begin(&mut self, label: &str) { + let vertex_kind = self.tool.vertex_kind.clone(); + 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(), vertex_kind }); + } + } + + 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, vertex_kind: i.vertex_kind }); + } + } + + /// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the + /// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled. + pub(crate) fn cancel_point_drag(&mut self) -> bool { + if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) { + return false; + } + self.tool.drag = None; + if let Some(st) = self.active_mut() + && st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points") + && let Some(i) = st.interaction.take() + { + // A modifier held during the drag may have set a point type; the drag is undone, so is that. + let kind = i.vertex_kind; + if !Arc::ptr_eq(&i.doc, &st.doc) { + st.doc = i.doc; + st.selection = i.selection; + st.revision += 1; + } + self.tool.vertex_kind = kind; + } + true + } + + /// End the interaction only when it is the one `label` started. A twist must not commit a drag. + pub(crate) fn commit_named(&mut self, label: &str) { + let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label); + if ours { + self.commit(); + } + } + + fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result { + if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() { + // Next click of a freeform: close on the start point, else add a vertex. + if points.len() > 2 && points.first().is_some_and(|s| (*s - p).hypot() <= tol * 2.0) { + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(true); + } + points.push(p); + self.tool.path_preview = points.clone(); + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true }); + return Ok(Value::Null); + } + match self.tool.kind.clone() { + ToolKind::Shape { preset } if is_freeform_tool(&preset) => { + self.tool.drag = Some(Drag::Freeform { mode: preset.clone(), points: vec![p], pressed: true }); + self.tool.path_preview = vec![p]; + self.tool.path_smooth = preset == "curve"; + self.tool.path_closing = false; + return Ok(Value::Null); + } + ToolKind::Shape { preset } if preset == TABLE_ERASER => { + // Click a border to merge the cells on either side; elsewhere does nothing. + return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(Value::Null)); + } + ToolKind::Shape { .. } | ToolKind::TextBox => { + self.tool.drag = Some(Drag::Create { start: p }); + self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); + return Ok(Value::Null); + } + ToolKind::Ink { mode, .. } => { + if mode == "eraser" { + return self.erase_at(p, tol); + } + let sample = self.ink_sample(p); + self.tool.drag = Some(Drag::Ink { points: vec![sample] }); + self.tool.ink_preview = vec![sample]; + return Ok(Value::Null); + } + ToolKind::Select => {} + } + // Clicking inside the text being edited moves the caret / starts a text selection. + let before = self.text_selection(); + if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? { + let press = self.doc()?.selection.text.clone(); + self.tool.drag = Some(Drag::TextSelect { shape, before, press }); + return Ok(json!({"caret": [pos.0, pos.1]})); + } + let hit = self.hit_test(p, tol); + // Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one; + // a click anywhere else leaves Edit Points. + if let Some(eid) = self.doc().ok().and_then(|st| self.tool.editing_points(st)) { + if let Some(Hit::Vertex { id, path, index }) = hit { + if mods.cmd { + return self.execute("shape.deletePoint", &json!({"id": id, "path": path, "index": index})); + } + self.tool.edit_vertex = Some((path, index)); + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Vertex { id, path, index }); + return Ok(json!({"vertex": [path, index]})); + } + if let Some(Hit::Handle { id, path, index, out }) = hit { + self.begin("Edit Points"); + self.tool.drag = Some(Drag::Handle { id, path, index, out }); + return Ok(json!({"handle": [path, index]})); + } + let st = self.doc()?; + if let Some(sh) = st.shape(eid) + && let Some((path, seg, t)) = cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6) + { + if mods.cmd { + return self.execute("shape.addPoint", &json!({"id": eid, "path": path, "segment": seg, "t": t})); + } + return Ok(Value::Null); + } + self.tool.edit_points = None; + self.tool.edit_vertex = None; + } + let st = self.doc()?; + match hit { + Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?, + Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?, + Some(Hit::Resize { id, handle }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Size"); + 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 }); + } + // Only reached outside Edit Points (handled above). + Some(Hit::Vertex { .. } | Hit::Handle { .. }) => {} + Some(Hit::Adjust { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + self.begin("Adjust Shape"); + self.tool.drag = Some(Drag::Adjust { id, index, orig }); + } + Some(Hit::Frame { id }) => { + // Like PowerPoint: the box leaves text editing and is selected whole (a click + // without a drag stops there), and dragging moves it as one undo step. + let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect(); + self.select(|_, sel| { + sel.shapes = vec![id]; + sel.text = None; + sel.cells = None; + })?; + self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None }); + } + Some(Hit::Warp { id, index }) => { + let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default(); + let found = st.shape(id).and_then(|sh| { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?; + let bp = deckcraft_model::resolve::body(&ctx, sh); + bp.warp.map(|n| (n, bp.warp_adj)) + }); + if let Some((name, base)) = found { + self.begin("Adjust WordArt"); + self.tool.drag = Some(Drag::Warp { id, index, name, base, orig }); + } + } + Some(Hit::Shape { id, .. }) + if !(mods.shift || mods.cmd) + && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) + && in_table_cell(st, id, p) => + { + // A press in a table whose cells are selected starts again in the cell under the + // pointer: a click puts the caret there and a drag selects another range. A press + // on the table outside its cells selects and moves the table like any shape. + let before = self.text_selection(); + if self.edit_text_at(id, p)? { + self.select(|_, sel| sel.cells = None)?; + let press = self.doc()?.selection.text.clone(); + self.tool.drag = Some(Drag::TextSelect { shape: id, before, press }); + } + } + 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] + }; + // Locked objects stay put when the selection is dragged. + let origs: Vec<(ShapeId, Xfrm)> = + ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect(); + // A click (no drag) on text puts the caret there; unselected text boxes and + // placeholders take the caret straight away like PowerPoint. A click in a cell of a + // selected table puts the caret there; a click on its grab band keeps the table selected. + let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_))); + // A click on the frame band of a text box selects the box whole, as PowerPoint + // does, so it can be moved, duplicated or deleted; only the inside takes the caret. + let on_frame = text + && is_text_first(st, id) + && st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame); + let enter_text = + (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected && in_table_cell(st, id, p)))) + .then_some((id, p)); + let ids2 = ids.clone(); + self.select(|_, sel| { + sel.shapes = ids2; + sel.text = None; + sel.cells = None; + })?; + let duplicate = mods.alt && origs.len() == ids.len(); + self.tool.drag = Some(Drag::Move { ids, start: p, origs, moved: false, duplicate, enter_text }); + // Format Painter applies on click. + if self.painter.is_some() && !toggle { + let _ = self.execute("format.painterApply", &json!({"ids": [id]})); + } + // Animation Painter applies on click. + if self.anim_painter.is_some() && !toggle { + let _ = self.execute("animation.painterApply", &json!({"ids": [id]})); + } + } + None if self.guide_at(p, tol).is_some() => { + // Away from every shape a press on a guide picks the guide up. + if let Some(index) = self.guide_at(p, tol) { + let pos = st.doc.guides.get(index).map(|g| g.pos).unwrap_or(0.0); + self.tool.drag = Some(Drag::Guide { index }); + self.tool.guide_drag = Some((index, pos)); + } + } + None => { + let add = mods.shift || mods.cmd; + self.select(|_, sel| { + 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 up(&mut self, p: Point, mods: Mods) -> Result { + let drag = self.tool.drag.take(); + self.tool.guides.clear(); + self.tool.sites.clear(); + self.tool.glue = None; + let mut out = Value::Null; + match drag { + Some(Drag::Guide { index }) => { + // Dropped off the slide: the guide is deleted. + let (_, pos) = self.tool.guide_drag.take().unwrap_or((index, 0.0)); + let size = self.doc()?.doc.slide_size; + let vertical = self.doc()?.doc.guides.get(index).is_some_and(|g| g.vertical); + let limit = if vertical { size.width } else { size.height }; + out = if pos < 0.0 || pos > limit { + self.execute("guide.delete", &json!({"index": index}))? + } else { + self.execute("guide.move", &json!({"index": index, "pos": pos}))? + }; + } + Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) + // Plain click on text: caret at the click (in a table, in the clicked cell). + if self.edit_text_at(id, at)? => { + 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 } if preset == DRAW_TABLE => { + // Inside a table: split cells along the line. Elsewhere: a one-cell table. + if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() { + self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null) + } else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) { + self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))? + } else { + Value::Null + } + } + ToolKind::Shape { preset } => { + let line = deckcraft_geom::preset::is_line_like(preset); + let rect = match preview.filter(|_| dragged) { + 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 id = ShapeId(id as u32); + let _ = self.set_xfrm_cmd(id, x); + // Glue the ends that landed on connection sites. + let (p0, p1) = crate::connect::endpoints(&x); + let (g0, g1) = (self.glue_at(p0, 4.0), self.glue_at(p1, 4.0)); + if g0.is_some() || g1.is_some() { + let _ = self.edit(|doc, sel| { + if let Some(list) = crate::shapes_mut(doc, sel) { + crate::connect::set_glue(list, id, 0, g0.map(|g| (g.0, g.1))); + crate::connect::set_glue(list, id, 1, g1.map(|g| (g.0, g.1))); + } + Ok(()) + }); + } + } + 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, + }; + // The table pencil stays on until you pick another tool, as in PowerPoint. + if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) { + self.tool.kind = ToolKind::Select; + } + out = res; + } + Some(Drag::Freeform { mode, points, .. }) => { + if mode == "scribble" { + // A scribble ending back at its start closes into a filled shape. + let closed = points.len() > 3 && points.first().zip(points.last()).is_some_and(|(a, b)| (*a - *b).hypot() <= 6.0); + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return self.finish_freeform(closed); + } + // Polygon / curve: keep going until double-click, Enter or a click on the start. + self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false }); + return Ok(Value::Null); + } + 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(Drag::TextSelect { before, .. }) => { + out = self.paint_if_selection_changed(before, out)?; + self.commit(); + } + Some(_) => self.commit(), + None => {} + } + // Any open interaction (move etc.) ends here. + self.commit(); + let _ = mods; + Ok(out) + } + + pub(super) 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(_))) + } +} diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 883ceb4e0..92d2cb44d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -168,332 +168,4 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 { } #[cfg(test)] -mod tests { - use super::*; - - fn any_face() -> std::sync::Arc { - FontDb::global().face(DEFAULT_FAMILY, "Regular") - } - - #[test] - fn emoji_clusters_are_recognised() { - let c = |s: &str| s.chars().collect::>(); - // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even - // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and - // keycaps always as text. - for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] - { - assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); - } - for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] - { - assert!(!is_emoji_cluster(&c(no)), "{no:?}"); - } - } - - #[test] - fn colour_fonts_are_recognised() { - let db = FontDb::with_font_dirs(Vec::new()); - assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); - assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); - assert!(!fontdb::last_resort_face().is_color()); - } - - #[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 line_split_follows_the_windows_ascent_and_descent() { - use skrifa::raw::TableProvider; - let f = fontdb::last_resort_face(); - let font = skrifa::FontRef::new(f.data()).unwrap(); - let os2 = font.os2().unwrap(); - let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); - assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); - assert!(f.line_split > 0.5 && f.line_split < 1.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 filtered_fallback_continues_after_a_rejected_loaded_face() { - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); - let first = db.fallback_for('A', u32::MAX).unwrap(); - let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; - let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); - assert_eq!(fallback.family, accepted_family); - assert!(fallback.covers('A')); - assert_ne!(fallback.id(), first.id()); - assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); - assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let db = FontDb::with_font_dirs(vec![dir]); - let first = db.fallback_for('A', u32::MAX).unwrap(); - assert_ne!(first.family, "DeckCraft Legacy"); - assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); - assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); - let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); - assert_eq!(accepted.family, "DeckCraft Legacy"); - assert!(accepted.covers('A')); - } - - #[test] - fn concurrent_lookups_all_find_an_installed_family() { - // Threads resolving an installed family nobody had loaded yet used to fall back to - // another face when a different thread finished loading it first, so the same text was - // laid out in different fonts depending on timing. - let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); - for _ in 0..20 { - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - let start = std::sync::Barrier::new(8); - let got: Vec = std::thread::scope(|s| { - let threads: Vec<_> = (0..8) - .map(|_| { - s.spawn(|| { - start.wait(); - db.face("Hack", "Regular").family.clone() - }) - }) - .collect(); - threads.into_iter().map(|t| t.join().unwrap()).collect() - }); - assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn windows_family_names_find_their_faces() { - // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's - // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. - let check = |db: &FontDb| { - assert!(db.has_family("DeckCraft Legacy Semibold")); - for style in ["Regular", "Bold"] { - let f = db.face("DeckCraft Legacy Semibold", style); - assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); - } - assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); - // The typographic name still works. - assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); - }; - // Loaded directly… - let db = FontDb::with_font_dirs(Vec::new()); - db.set_system_fallback(false); - assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); - check(&db); - // …and found by the system font scan under its Windows name, then loaded. - #[cfg(not(target_arch = "wasm32"))] - { - let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - check(&db); - // Looking up the Windows name loads the Semibold file only; a later lookup of the - // family's Regular must still load and pick the Regular file, not settle for Semibold. - let db = FontDb::with_font_dirs(vec![dir]); - db.set_system_fallback(false); - assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); - let regular = db.face("DeckCraft Legacy", "Regular"); - assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); - } - } - - #[test] - fn faces_without_outlines_are_not_cataloged() { - // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in - // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag - // keeps the table directory sorted. - let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); - let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; - let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); - semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); - let loaded = FontDb::with_font_dirs(Vec::new()); - assert_eq!(loaded.add_font(semibold.clone()), 1); - let face = loaded.face("DeckCraft Legacy", "Semibold"); - assert!(face.covers('A')); - assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); - // The system font scan lists the Regular, which has outlines, and leaves out the Semibold - // under both of its names. - #[cfg(not(target_arch = "wasm32"))] - { - let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); - std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); - let db = FontDb::with_font_dirs(vec![dir.clone()]); - db.set_system_fallback(false); - assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); - assert!(!db.has_family("DeckCraft Legacy Semibold")); - let _ = std::fs::remove_dir_all(&dir); - } - } - - #[test] - fn missing_document_fonts_are_drawn_in_calibri() { - // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. - let db = FontDb::global(); - for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { - let f = db.document_face(missing, "Bold"); - assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); - } - // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. - for (missing, twin) in - [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] - { - if !db.has_family(missing) { - assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); - } - } - // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. - // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. - for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { - assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); - } - } - - #[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()); - } - - /// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; - /// required builds also assert their presence. - #[test] - fn craft_fonts_latin_when_present() { - let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); - if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { - assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); - eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); - return; - } - let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); - let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); - if required { - assert!( - variable.is_some() || (regular.is_some() && bold.is_some()), - "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" - ); - } - 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 let (Some(regular), Some(bold)) = (regular, bold) { - // The build input carries one entry per face; real static Bold must not be the Regular - // file relabelled in the manifest. - assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); - let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); - assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); - assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); - assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); - assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); - } - if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { - assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); - } - } - - /// An Embedded OpenType header of `version` around `font`, with the font at the end. - fn wrap_eot(font: &[u8], version: u32) -> Vec { - let mut h = vec![0u8; 82]; - let total = 82 + font.len(); - h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); - h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); - h[8..12].copy_from_slice(&version.to_le_bytes()); - h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); - h.extend_from_slice(font); - h - } - - #[test] - fn embedded_open_type_yields_the_font() { - let font = epaint_default_fonts::UBUNTU_LIGHT; - assert_eq!(embedded_sfnt(font), Some(font)); - assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); - assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); - assert!(embedded_sfnt(b"not a font file").is_none()); - assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); - let mut truncated = wrap_eot(font, 0x0002_0001); - truncated.truncate(40); - assert!(embedded_sfnt(&truncated).is_none()); - } - - /// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them - /// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and - /// still finds the real font beside them. - #[cfg(unix)] - #[test] - fn the_font_scan_skips_fifos_folders_and_link_loops() { - let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join("sub")).unwrap(); - std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); - let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); - std::fs::create_dir(dir.join("folder.ttf")).unwrap(); - std::fs::write(dir.join("empty.otf"), b"").unwrap(); - std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); - let (tx, rx) = std::sync::mpsc::channel(); - let scan_dir = dir.clone(); - std::thread::spawn(move || { - let db = FontDb::with_font_dirs(vec![scan_dir]); - let _ = tx.send(db.load_system_fonts()); - }); - let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); - assert!(n >= 1, "the real font was found"); - assert!(fifo || n >= 1); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs new file mode 100644 index 000000000..bb7b726ab --- /dev/null +++ b/crates/fonts/src/tests.rs @@ -0,0 +1,326 @@ +use super::*; + +fn any_face() -> std::sync::Arc { + FontDb::global().face(DEFAULT_FAMILY, "Regular") +} + +#[test] +fn emoji_clusters_are_recognised() { + let c = |s: &str| s.chars().collect::>(); + // As PowerPoint draws them: emoji-presentation characters (⭐ ✅ ☔ ⌚ ⏩) in colour even + // without a selector, text-presentation ones (♥ ✔ ☎ ⬆ ▶) as text unless U+FE0F asks, and + // keycaps always as text. + for yes in ["😀", "👍🏽", "❤\u{FE0F}", "👨\u{200D}👩\u{200D}👧", "🇯🇵", "🏳\u{FE0F}\u{200D}🌈", "⭐", "✅", "☔", "⌚", "⏩", "🀄"] + { + assert!(is_emoji_cluster(&c(yes)), "{yes:?}"); + } + for no in ["a", "1", "❤", "☺\u{FE0E}", "😀\u{FE0E}", "字", "→", "♥", "✔", "☎", "⬆", "▶", "1\u{FE0F}\u{20E3}", "#\u{20E3}"] { + assert!(!is_emoji_cluster(&c(no)), "{no:?}"); + } +} + +#[test] +fn colour_fonts_are_recognised() { + let db = FontDb::with_font_dirs(Vec::new()); + assert!(db.add_font(include_bytes!("../../render/tests/fixtures/colr-squares.ttf").to_vec()) > 0); + assert!(db.face("DeckCraft Colr Squares", "Regular").is_color()); + assert!(!fontdb::last_resort_face().is_color()); +} + +#[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 line_split_follows_the_windows_ascent_and_descent() { + use skrifa::raw::TableProvider; + let f = fontdb::last_resort_face(); + let font = skrifa::FontRef::new(f.data()).unwrap(); + let os2 = font.os2().unwrap(); + let (a, d) = (os2.us_win_ascent() as f64, os2.us_win_descent() as f64); + assert!((f.line_split - a / (a + d)).abs() < 1e-9, "{} vs {}", f.line_split, a / (a + d)); + assert!(f.line_split > 0.5 && f.line_split < 1.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 filtered_fallback_continues_after_a_rejected_loaded_face() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); + let first = db.fallback_for('A', u32::MAX).unwrap(); + let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; + let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); + assert_eq!(fallback.family, accepted_family); + assert!(fallback.covers('A')); + assert_ne!(fallback.id(), first.id()); + assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); + assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir]); + let first = db.fallback_for('A', u32::MAX).unwrap(); + assert_ne!(first.family, "DeckCraft Legacy"); + assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); + assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); + let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); + assert_eq!(accepted.family, "DeckCraft Legacy"); + assert!(accepted.covers('A')); +} + +#[test] +fn concurrent_lookups_all_find_an_installed_family() { + // Threads resolving an installed family nobody had loaded yet used to fall back to + // another face when a different thread finished loading it first, so the same text was + // laid out in different fonts depending on timing. + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-concurrent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for _ in 0..20 { + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + let start = std::sync::Barrier::new(8); + let got: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + db.face("Hack", "Regular").family.clone() + }) + }) + .collect(); + threads.into_iter().map(|t| t.join().unwrap()).collect() + }); + assert!(got.iter().all(|f| f == "Hack"), "{got:?}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn windows_family_names_find_their_faces() { + // Documents name fonts by their Windows family ("Segoe UI Semibold"); the font's + // typographic family is "Segoe UI" with a "Semibold" style. The fixture is named the same way. + let check = |db: &FontDb| { + assert!(db.has_family("DeckCraft Legacy Semibold")); + for style in ["Regular", "Bold"] { + let f = db.face("DeckCraft Legacy Semibold", style); + assert_eq!((f.family.as_str(), f.style.as_str()), ("DeckCraft Legacy", "Semibold"), "{style}"); + } + assert_eq!(db.document_face("DeckCraft Legacy Semibold", "Regular").family, "DeckCraft Legacy", "installed, so not Calibri"); + // The typographic name still works. + assert_eq!(db.face("DeckCraft Legacy", "Semibold").style, "Semibold"); + }; + // Loaded directly… + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec()), 1); + check(&db); + // …and found by the system font scan under its Windows name, then loaded. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + check(&db); + // Looking up the Windows name loads the Semibold file only; a later lookup of the + // family's Regular must still load and pick the Regular file, not settle for Semibold. + let db = FontDb::with_font_dirs(vec![dir]); + db.set_system_fallback(false); + assert_eq!(db.face("DeckCraft Legacy Semibold", "Regular").style, "Semibold"); + let regular = db.face("DeckCraft Legacy", "Regular"); + assert_eq!((regular.style.as_str(), regular.weight), ("Regular", 400.0)); + } +} + +#[test] +fn faces_without_outlines_are_not_cataloged() { + // Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in + // Apple's `hvgl` table. The face still maps "A", but its outline is empty. The new tag + // keeps the table directory sorted. + let mut semibold = include_bytes!("../tests/fixtures/legacy-semibold.ttf").to_vec(); + let tables = u16::from_be_bytes([semibold[4], semibold[5]]) as usize; + let glyf = (0..tables).map(|i| 12 + 16 * i).find(|&r| &semibold[r..r + 4] == b"glyf").unwrap(); + semibold[glyf..glyf + 4].copy_from_slice(b"gxyz"); + let loaded = FontDb::with_font_dirs(Vec::new()); + assert_eq!(loaded.add_font(semibold.clone()), 1); + let face = loaded.face("DeckCraft Legacy", "Semibold"); + assert!(face.covers('A')); + assert!(loaded.outline(&face, face.glyph_for('A')).elements().is_empty()); + // The system font scan lists the Regular, which has outlines, and leaves out the Semibold + // under both of its names. + #[cfg(not(target_arch = "wasm32"))] + { + let dir = std::env::temp_dir().join(format!("deckcraft-fonts-outlines-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("legacy-semibold.ttf"), &semibold).unwrap(); + std::fs::write(dir.join("legacy-regular.ttf"), include_bytes!("../tests/fixtures/legacy-regular.ttf")).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + db.set_system_fallback(false); + assert_eq!(db.styles("DeckCraft Legacy"), ["Regular"]); + assert!(!db.has_family("DeckCraft Legacy Semibold")); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[test] +fn missing_document_fonts_are_drawn_in_calibri() { + // PowerPoint draws a missing family in Calibri, whatever its panose or the theme says. + let db = FontDb::global(); + for missing in ["Qqq Nonexistent", "Avenir Next Qqq", "", "Arimo Qqq"] { + let f = db.document_face(missing, "Bold"); + assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}"); + } + // PowerPoint's own twins and Windows' substitutes, when the font itself is missing. + for (missing, twin) in + [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")] + { + if !db.has_family(missing) { + assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}"); + } + } + // Metric-compatible stand-ins and DeckCraft's default keep their own fallbacks. + // So do Office's cloud fonts, which PowerPoint downloads and draws as themselves. + for kept in ["Arial", "Times New Roman", "Cambria", "Courier New", FALLBACK_FAMILY, "Aptos", "Aptos Display", "Grandview"] { + assert_eq!(db.document_face(kept, "Regular").family, db.face(kept, "Regular").family, "{kept}"); + } +} + +#[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()); +} + +/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; +/// required builds also assert their presence. +#[test] +fn craft_fonts_latin_when_present() { + let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); + if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { + assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); + eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); + return; + } + let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); + let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); + if required { + assert!( + variable.is_some() || (regular.is_some() && bold.is_some()), + "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" + ); + } + 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 let (Some(regular), Some(bold)) = (regular, bold) { + // The build input carries one entry per face; real static Bold must not be the Regular + // file relabelled in the manifest. + assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); + let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); + assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); + assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); + assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); + assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); + } + if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { + assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); + } +} + +/// An Embedded OpenType header of `version` around `font`, with the font at the end. +fn wrap_eot(font: &[u8], version: u32) -> Vec { + let mut h = vec![0u8; 82]; + let total = 82 + font.len(); + h[0..4].copy_from_slice(&(total as u32).to_le_bytes()); + h[4..8].copy_from_slice(&(font.len() as u32).to_le_bytes()); + h[8..12].copy_from_slice(&version.to_le_bytes()); + h[34..36].copy_from_slice(&0x504Cu16.to_le_bytes()); + h.extend_from_slice(font); + h +} + +#[test] +fn embedded_open_type_yields_the_font() { + let font = epaint_default_fonts::UBUNTU_LIGHT; + assert_eq!(embedded_sfnt(font), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0002_0001)), Some(font)); + assert_eq!(embedded_sfnt(&wrap_eot(font, 0x0001_0000)), Some(font)); + assert!(embedded_sfnt(b"not a font file").is_none()); + assert!(embedded_sfnt(&wrap_eot(b"hello world!!", 0x0002_0002)).is_none()); + let mut truncated = wrap_eot(font, 0x0002_0001); + truncated.truncate(40); + assert!(embedded_sfnt(&truncated).is_none()); +} + +/// A FIFO, a folder and a link loop dressed up as font files: the system scan skips them +/// instead of blocking forever on the FIFO (start-up hung when one sat in `~/.fonts`), and +/// still finds the real font beside them. +#[cfg(unix)] +#[test] +fn the_font_scan_skips_fifos_folders_and_link_loops() { + let dir = std::env::temp_dir().join(format!("deckcraft-fontscan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::copy(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy-semibold.ttf"), dir.join("sub/real.ttf")).unwrap(); + let fifo = std::process::Command::new("mkfifo").arg(dir.join("trap.ttf")).status().is_ok_and(|s| s.success()); + std::fs::create_dir(dir.join("folder.ttf")).unwrap(); + std::fs::write(dir.join("empty.otf"), b"").unwrap(); + std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let scan_dir = dir.clone(); + std::thread::spawn(move || { + let db = FontDb::with_font_dirs(vec![scan_dir]); + let _ = tx.send(db.load_system_fonts()); + }); + let n = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the font scan hung"); + assert!(n >= 1, "the real font was found"); + assert!(fifo || n >= 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index cea34a6a6..7610398e6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -673,435 +673,7 @@ pub fn slides_to_outline(p: &Presentation) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn chart_sidecars_are_optional_for_older_native_files() { - use deckcraft_model::chart::{Chart, ChartType}; - let chart = Chart::sample(ChartType::Pie); - let mut json = serde_json::to_value(&chart).expect("chart JSON"); - assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); - assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); - json["kept"] = serde_json::json!({}); - assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); - } - - #[test] - fn native_save_keeps_chart_relationships_and_payloads() { - use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; - use deckcraft_model::{Shape, ShapeId, ShapeKind}; - let kept = ChartKept { - rels: vec![ChartRel { - id: "rWorkbook".into(), - rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), - target: "../embeddings/data.xlsx".into(), - part: "ppt/embeddings/data.xlsx".into(), - external: false, - }], - parts: vec![ChartPart { - name: "ppt/embeddings/data.xlsx".into(), - content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), - bytes: vec![0, 255, 128, 42], - native_entry: None, - }], - }; - let mut p = defaults::new_presentation(None); - let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; - let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; - // Serialization must reach charts inside groups as well as top-level charts. - Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { - id: ShapeId(p.alloc_id().expect("group id")), - kind: ShapeKind::Group { children: vec![s], child: Default::default() }, - ..Default::default() - }]; - let mut q = load(&save(&p).expect("save")).expect("load"); - for _ in 0..2 { - let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; - let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; - assert_eq!(chart.kept, kept); - q = load(&save(&q).expect("save again")).expect("load again"); - } - } - - /// The same package without the entries `drop` names. - fn without(bytes: &[u8], drop: &str) -> Vec { - let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - for i in 0..z.len() { - let f = z.by_index(i).unwrap(); - if f.name() != drop { - out.raw_copy_file(f).unwrap(); - } - } - out.finish().unwrap().into_inner() - } - - #[test] - fn missing_or_corrupt_media_is_reported() { - let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); - let good = save(&p).unwrap(); - assert!(load_checked(&good).unwrap().1.is_empty()); - // Missing member. - let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); - assert_eq!(damaged.len(), 1, "{damaged:?}"); - assert!(q.media(id).is_some_and(|m| m.data.is_empty())); - // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. - let mut bad = good.clone(); - let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); - bad[at] ^= 0xff; - let (_, damaged) = load_checked(&bad).unwrap(); - assert_eq!(damaged.len(), 1, "{damaged:?}"); - // Linked media saved without its bytes isn't damage. - let mut l = Presentation::default(); - let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); - if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { - m.link = Some("file:///movies/v.mp4".into()); - } - assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); - } - - #[test] - fn media_budget_is_cumulative() { - let mut p = Presentation::default(); - p.add_media("a.bin", "application/octet-stream", vec![1; 600]); - p.add_media("b.bin", "application/octet-stream", vec![2; 600]); - let bytes = save(&p).unwrap(); - // Exactly at the limit opens; one byte under is refused, though each item fits. - assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); - assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); - assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); - } - - #[test] - fn repeated_media_ids_are_read_once() { - use std::io::Write; - let mut p = Presentation::default(); - p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); - let good = save(&p).unwrap(); - // The same file with its media item listed 51 times. - let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); - let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); - for i in 0..z.len() { - let mut f = z.by_index(i).unwrap(); - if f.name() == "presentation.json" { - let mut json = vec![]; - f.read_to_end(&mut json).unwrap(); - let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); - let media = &mut v["presentation"]["media"]; - let one = media[0].clone(); - *media = serde_json::Value::Array(vec![one; 51]); - out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); - out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); - } else { - out.raw_copy_file(f).unwrap(); - } - } - let bytes = out.finish().unwrap().into_inner(); - // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. - let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; - assert_eq!(q.media.len(), 1); - assert_eq!(q.media[0].data.len(), 1000); - // And the deck saves again. - assert!(save(&q).is_ok()); - } - - #[test] - fn roundtrip_with_media() { - let mut p = Presentation::default(); - let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); - 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()); - } - - /// A video's crop is brought into the same range as a picture's (#972). - #[test] - fn a_video_crop_is_repaired_like_a_picture_crop() { - use deckcraft_model::{MediaClip, Shape}; - let mut p = Presentation::default(); - let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; - assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); - } - - /// Non-finite numbers are written as `null` by serde_json; the file still opens, with each - /// such number repaired (it failed as "invalid type: null, expected f64"). - #[test] - fn a_file_saved_with_non_finite_numbers_reopens() { - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = Presentation::default(); - let nan = f64::NAN; - let inf = f64::INFINITY; - let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; - pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; - pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); - let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; - let mut body = deckcraft_model::text::TextBody::default(); - let mut para = deckcraft_model::text::Paragraph::default(); - para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); - para.end_props.size = Some(1e9); - body.paragraphs.push(para); - text.text = Some(body); - let group = Shape { - xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), - kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, - ..Default::default() - }; - Arc::make_mut(&mut p.slides[0]).shapes.push(group); - let bytes = save(&p).unwrap(); - let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); - let g = back.slides[0].shapes.last().unwrap(); - let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; - assert_eq!((child.x, child.w), (0.0, 0.0)); - let x = children[0].xfrm.unwrap(); - assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); - let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; - assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); - assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); - assert_eq!(children[1].xfrm.unwrap().rot, 0.0); - let para = &children[1].text.as_ref().unwrap().paragraphs[0]; - assert_eq!(para.runs[0].props.size, None); - assert_eq!(para.end_props.size, Some(4000.0)); - // Saved again, it is clean. - assert_eq!(load(&save(&back).unwrap()).unwrap(), back); - } - - /// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, - /// including a `null` inside a tagged enum, where serde reports only the enclosing object. - #[test] - fn older_files_with_null_numbers_open() { - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let nan = f64::NAN; - for case in 0..3 { - let mut p = Presentation::default(); - let sh = match case { - 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, - 1 => Shape { - kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, - ..Default::default() - }, - _ => Shape { - kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, - ..Default::default() - }, - }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; - let json = serde_json::to_vec(&m).unwrap(); - assert!(json.windows(4).any(|w| w == b"null")); - let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); - assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); - } - } - - /// A body whose file had no `fontScale` (``) keeps that state through the - /// native format: it is drawn at 100 % and only fitted when its text changes (#724). - #[test] - fn a_bare_normautofit_body_round_trips() { - use deckcraft_model::text::{AutoFit, TextBody}; - let mut p = Presentation::default(); - let mut body = TextBody::from_text("Overflowing"); - body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }); - let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); - assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); - // A scale the file saved round-trips as itself. - let mut body = TextBody::from_text("Overflowing"); - body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }); - let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; - Arc::make_mut(&mut p.slides[0]).shapes.push(sh); - let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); - let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); - assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 })); - } - - /// Only `null`s where numbers belong are patched: other damage still fails. - #[test] - fn other_damage_is_still_reported() { - let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); - assert!(parse_manifest(&ok).is_ok()); - let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; - assert!(parse_manifest(bad).is_err()); - let truncated = &ok[..ok.len() - 3]; - assert!(parse_manifest(truncated).is_err()); - // A null number on a later line (pretty-printed by another tool) is found too. - let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; - let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); - assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); - } - - #[test] - fn degenerate_slide_sizes_are_repaired() { - use deckcraft_model::Size; - let cases = [ - ((0.0, 0.0), (960.0, 540.0)), - ((-5.0, 540.0), (960.0, 540.0)), - ((f64::NAN, 540.0), (960.0, 540.0)), - ((f64::INFINITY, 1.0), (960.0, 540.0)), - ((1.0, 1.0), (72.0, 72.0)), - ((0.5, 4000.0), (72.0, 4000.0)), - ((1e9, 20.0), (4032.0, 72.0)), - ((720.0, 540.0), (720.0, 540.0)), - ]; - for ((w, h), want) in cases { - let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; - repair(&mut p); - assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); - assert_eq!(p.notes_size, Presentation::default().notes_size); - } - } - - /// A file whose slide size is null, a string or missing a side (written by hand or by - /// another tool; NaN is also saved as null) opens with the default size rather than failing - /// as damaged. - #[test] - fn files_with_unreadable_slide_sizes_still_open() { - let saved = save(&Presentation::default()).unwrap(); - let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); - let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); - for size in [ - serde_json::json!({"width": null, "height": 540}), - serde_json::json!({"width": "big", "height": 540}), - serde_json::json!({"height": 0}), - serde_json::json!(null), - serde_json::json!(7), - ] { - let mut m = json.clone(); - m["presentation"]["slideSize"] = size.clone(); - let mut buf = Cursor::new(Vec::new()); - { - let mut w = zip::ZipWriter::new(&mut buf); - let o = zip::write::SimpleFileOptions::default(); - w.start_file("mimetype", o).unwrap(); - w.write_all(MIMETYPE.as_bytes()).unwrap(); - w.start_file("presentation.json", o).unwrap(); - w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); - w.finish().unwrap(); - } - let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); - assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); - } - } - - #[test] - fn repair_fixes_missing_layout_and_size() { - let mut p = Presentation::default(); - Arc::make_mut(&mut p.slides[0]).layout = deckcraft_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); - } - - /// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is - /// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). - #[test] - fn repair_keeps_a_duplicate_it_cannot_reid() { - let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; - p.slides.push(p.slides[0].clone()); - repair(&mut p); - assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); - assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); - } - - #[test] - fn outline_roundtrip() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - 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}"); - } - - #[test] - fn outline_keeps_leading_dash_text() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other"); - assert_eq!(n, 2); - let o = slides_to_outline(&p); - assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); - } - - #[test] - fn outline_export_keeps_multi_paragraph_title_on_one_line() { - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); - let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); - title.text = Some(TextBody::from_text("Line one\nLine two")); - let o = slides_to_outline(&p); - assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); - let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - assert_eq!(outline_to_slides(&mut q, &o), 2); - } - - #[test] - fn markdown_headings_and_list_items() { - assert_eq!(md_heading("# Intro"), Some("Intro")); - assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); - assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); - assert_eq!(md_heading("#"), Some("")); - assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); - for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { - assert_eq!(md_heading(not), None, "{not:?}"); - } - assert_eq!(md_list_item("- a"), Some((0, "a"))); - assert_eq!(md_list_item(" * b"), Some((2, "b"))); - assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); - assert_eq!(md_list_item("12) d"), Some((0, "d"))); - for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { - assert_eq!(md_list_item(not), None, "{not:?}"); - } - } - - /// #814: slides_to_outline includes table cell text, which it used to skip. - #[test] - fn outline_includes_table_text() { - use deckcraft_model::table::{Cell, Row, Table}; - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = defaults::new_presentation(None); - let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); - let mut s = defaults::new_slide(&mut p, layout).expect("slide"); - s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); - let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; - body.text = Some(TextBody::from_text("Text box body")); - s.shapes.push(body); - - let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; - for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { - let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); - table.rows.push(Row { height: 30.0, cells }); - } - let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; - tbl.kind = ShapeKind::Table(table); - s.shapes.push(tbl); - p.slides = vec![std::sync::Arc::new(s)]; - - let out = slides_to_outline(&p); - assert!(out.contains("Text box body"), "{out}"); - assert!(out.contains("Cell A1\tCell B1"), "{out}"); - assert!(out.contains("Cell A2\tCell B2"), "{out}"); - } -} +mod tests; #[test] fn outline_table_rows_are_indented_and_do_not_become_slides() { diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs new file mode 100644 index 000000000..dcd156367 --- /dev/null +++ b/crates/format/src/tests.rs @@ -0,0 +1,427 @@ +use super::*; + +#[test] +fn chart_sidecars_are_optional_for_older_native_files() { + use deckcraft_model::chart::{Chart, ChartType}; + let chart = Chart::sample(ChartType::Pie); + let mut json = serde_json::to_value(&chart).expect("chart JSON"); + assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); + assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); + json["kept"] = serde_json::json!({}); + assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); +} + +#[test] +fn native_save_keeps_chart_relationships_and_payloads() { + use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let kept = ChartKept { + rels: vec![ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/data.xlsx".into(), + part: "ppt/embeddings/data.xlsx".into(), + external: false, + }], + parts: vec![ChartPart { + name: "ppt/embeddings/data.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: vec![0, 255, 128, 42], + native_entry: None, + }], + }; + let mut p = defaults::new_presentation(None); + let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; + let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + // Serialization must reach charts inside groups as well as top-level charts. + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![s], child: Default::default() }, + ..Default::default() + }]; + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept, kept); + q = load(&save(&q).expect("save again")).expect("load again"); + } +} + +/// The same package without the entries `drop` names. +fn without(bytes: &[u8], drop: &str) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != drop { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() +} + +#[test] +fn missing_or_corrupt_media_is_reported() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec()).expect("media"); + let good = save(&p).unwrap(); + assert!(load_checked(&good).unwrap().1.is_empty()); + // Missing member. + let (q, damaged) = load_checked(&without(&good, &format!("media/{}", id.0))).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + assert!(q.media(id).is_some_and(|m| m.data.is_empty())); + // Bad CRC: PNGs are stored, so their bytes sit in the file as they are. + let mut bad = good.clone(); + let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap(); + bad[at] ^= 0xff; + let (_, damaged) = load_checked(&bad).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + // Linked media saved without its bytes isn't damage. + let mut l = Presentation::default(); + let lid = l.add_media("v.mp4", "video/mp4", vec![]).expect("media"); + if let Some(m) = l.media.iter_mut().find(|m| m.id == lid) { + m.link = Some("file:///movies/v.mp4".into()); + } + assert!(load_checked(&save(&l).unwrap()).unwrap().1.is_empty()); +} + +#[test] +fn media_budget_is_cumulative() { + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![1; 600]); + p.add_media("b.bin", "application/octet-stream", vec![2; 600]); + let bytes = save(&p).unwrap(); + // Exactly at the limit opens; one byte under is refused, though each item fits. + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err()); +} + +#[test] +fn repeated_media_ids_are_read_once() { + use std::io::Write; + let mut p = Presentation::default(); + p.add_media("a.bin", "application/octet-stream", vec![7; 1000]); + let good = save(&p).unwrap(); + // The same file with its media item listed 51 times. + let mut z = zip::ZipArchive::new(Cursor::new(good.as_slice())).unwrap(); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + let mut json = vec![]; + f.read_to_end(&mut json).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&json).unwrap(); + let media = &mut v["presentation"]["media"]; + let one = media[0].clone(); + *media = serde_json::Value::Array(vec![one; 51]); + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&v).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + let bytes = out.finish().unwrap().into_inner(); + // 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once. + let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0; + assert_eq!(q.media.len(), 1); + assert_eq!(q.media[0].data.len(), 1000); + // And the deck saves again. + assert!(save(&q).is_ok()); +} + +#[test] +fn roundtrip_with_media() { + let mut p = Presentation::default(); + let id = p.add_media("a.png", "image/png", vec![1, 2, 3, 4]).expect("media"); + 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()); +} + +/// A video's crop is brought into the same range as a picture's (#972). +#[test] +fn a_video_crop_is_repaired_like_a_picture_crop() { + use deckcraft_model::{MediaClip, Shape}; + let mut p = Presentation::default(); + let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() }); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") }; + assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]); +} + +/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each +/// such number repaired (it failed as "invalid type: null, expected f64"). +#[test] +fn a_file_saved_with_non_finite_numbers_reopens() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = Presentation::default(); + let nan = f64::NAN; + let inf = f64::INFINITY; + let mut pic = Shape { xfrm: Some(Xfrm { x: nan, y: -inf, w: 1e300, h: -4.0, rot: 725.0, ..Default::default() }), ..Default::default() }; + pic.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [nan, inf, 0.25, -1e9], ..Default::default() } }; + pic.line = Some(deckcraft_model::Line { width: Some(1e9), ..Default::default() }); + let mut text = Shape { xfrm: Some(Xfrm { rot: inf, w: 100.0, h: 50.0, ..Default::default() }), ..Default::default() }; + let mut body = deckcraft_model::text::TextBody::default(); + let mut para = deckcraft_model::text::Paragraph::default(); + para.runs.push(deckcraft_model::text::Run::with("x", deckcraft_model::text::RunProps { size: Some(-3.0), ..Default::default() })); + para.end_props.size = Some(1e9); + body.paragraphs.push(para); + text.text = Some(body); + let group = Shape { + xfrm: Some(Xfrm { w: 10.0, h: 10.0, ..Default::default() }), + kind: ShapeKind::Group { children: vec![pic, text], child: Xfrm { x: nan, w: inf, h: 10.0, ..Default::default() } }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(group); + let bytes = save(&p).unwrap(); + let back = load(&bytes).unwrap_or_else(|e| panic!("{e}")); + let g = back.slides[0].shapes.last().unwrap(); + let ShapeKind::Group { children, child } = &g.kind else { panic!("group") }; + assert_eq!((child.x, child.w), (0.0, 0.0)); + let x = children[0].xfrm.unwrap(); + assert_eq!((x.x, x.y, x.w, x.h, x.rot), (0.0, 0.0, MAX_COORD, 0.0, 5.0)); + let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture") }; + assert_eq!(fill.crop, [0.0, 0.0, 0.25, -10.0]); + assert_eq!(children[0].line.as_ref().unwrap().width, Some(1584.0)); + assert_eq!(children[1].xfrm.unwrap().rot, 0.0); + let para = &children[1].text.as_ref().unwrap().paragraphs[0]; + assert_eq!(para.runs[0].props.size, None); + assert_eq!(para.end_props.size, Some(4000.0)); + // Saved again, it is clean. + assert_eq!(load(&save(&back).unwrap()).unwrap(), back); +} + +/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too, +/// including a `null` inside a tagged enum, where serde reports only the enclosing object. +#[test] +fn older_files_with_null_numbers_open() { + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let nan = f64::NAN; + for case in 0..3 { + let mut p = Presentation::default(); + let sh = match case { + 0 => Shape { xfrm: Some(Xfrm { x: nan, rot: nan, w: 5.0, h: 5.0, ..Default::default() }), ..Default::default() }, + 1 => Shape { + kind: ShapeKind::Group { children: vec![], child: Xfrm { x: nan, w: 1.0, h: 1.0, ..Default::default() } }, + ..Default::default() + }, + _ => Shape { + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop: [0.1, nan, 0.0, 0.0], ..Default::default() } }, + ..Default::default() + }, + }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let m = Manifest { format: "deckcraft".into(), version: VERSION, generator: "old".into(), presentation: p }; + let json = serde_json::to_vec(&m).unwrap(); + assert!(json.windows(4).any(|w| w == b"null")); + let back = parse_manifest(&json).unwrap_or_else(|e| panic!("case {case}: {e}")); + assert_eq!(back.presentation.slides[0].shapes.len(), m.presentation.slides[0].shapes.len(), "case {case}"); + } +} + +/// A body whose file had no `fontScale` (``) keeps that state through the +/// native format: it is drawn at 100 % and only fitted when its text changes (#724). +#[test] +fn a_bare_normautofit_body_round_trips() { + use deckcraft_model::text::{AutoFit, TextBody}; + let mut p = Presentation::default(); + let mut body = TextBody::from_text("Overflowing"); + body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }); + let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); + assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 })); + // A scale the file saved round-trips as itself. + let mut body = TextBody::from_text("Overflowing"); + body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }); + let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() }; + Arc::make_mut(&mut p.slides[0]).shapes.push(sh); + let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}")); + let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap(); + assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 })); +} + +/// Only `null`s where numbers belong are patched: other damage still fails. +#[test] +fn other_damage_is_still_reported() { + let ok = serde_json::to_vec(&serde_json::json!({"format": "deckcraft", "version": 1, "generator": "t", "presentation": {}})).unwrap(); + assert!(parse_manifest(&ok).is_ok()); + let bad = br#"{"format": null, "version": 1, "generator": "t", "presentation": {}}"#; + assert!(parse_manifest(bad).is_err()); + let truncated = &ok[..ok.len() - 3]; + assert!(parse_manifest(truncated).is_err()); + // A null number on a later line (pretty-printed by another tool) is found too. + let pretty = b"{\n \"format\": \"deckcraft\",\n \"version\": 1,\n \"generator\": \"t\",\n \"presentation\": {\"slides\": [{\"shapes\": [{\"xfrm\": {\"x\": null, \"y\": 1, \"w\": 2, \"h\": 3}}]}]}\n}"; + let m = parse_manifest(pretty).unwrap_or_else(|e| panic!("{e}")); + assert_eq!(m.presentation.slides[0].shapes[0].xfrm.unwrap().x, 0.0); +} + +#[test] +fn degenerate_slide_sizes_are_repaired() { + use deckcraft_model::Size; + let cases = [ + ((0.0, 0.0), (960.0, 540.0)), + ((-5.0, 540.0), (960.0, 540.0)), + ((f64::NAN, 540.0), (960.0, 540.0)), + ((f64::INFINITY, 1.0), (960.0, 540.0)), + ((1.0, 1.0), (72.0, 72.0)), + ((0.5, 4000.0), (72.0, 4000.0)), + ((1e9, 20.0), (4032.0, 72.0)), + ((720.0, 540.0), (720.0, 540.0)), + ]; + for ((w, h), want) in cases { + let mut p = Presentation { slide_size: Size::new(w, h), notes_size: Size::new(0.0, f64::NAN), ..Default::default() }; + repair(&mut p); + assert_eq!((p.slide_size.width, p.slide_size.height), want, "{w}x{h}"); + assert_eq!(p.notes_size, Presentation::default().notes_size); + } +} + +/// A file whose slide size is null, a string or missing a side (written by hand or by +/// another tool; NaN is also saved as null) opens with the default size rather than failing +/// as damaged. +#[test] +fn files_with_unreadable_slide_sizes_still_open() { + let saved = save(&Presentation::default()).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved[..])).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + for size in [ + serde_json::json!({"width": null, "height": 540}), + serde_json::json!({"width": "big", "height": 540}), + serde_json::json!({"height": 0}), + serde_json::json!(null), + serde_json::json!(7), + ] { + let mut m = json.clone(); + m["presentation"]["slideSize"] = size.clone(); + let mut buf = Cursor::new(Vec::new()); + { + let mut w = zip::ZipWriter::new(&mut buf); + let o = zip::write::SimpleFileOptions::default(); + w.start_file("mimetype", o).unwrap(); + w.write_all(MIMETYPE.as_bytes()).unwrap(); + w.start_file("presentation.json", o).unwrap(); + w.write_all(&serde_json::to_vec(&m).unwrap()).unwrap(); + w.finish().unwrap(); + } + let p = load(buf.get_ref()).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!((p.slide_size.width, p.slide_size.height), (960.0, 540.0), "{size}"); + } +} + +#[test] +fn repair_fixes_missing_layout_and_size() { + let mut p = Presentation::default(); + Arc::make_mut(&mut p.slides[0]).layout = deckcraft_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); +} + +/// A duplicate slide in a deck whose ids are used up cannot be re-id'd: it stays as it is +/// (the engine refuses edits to such a deck) instead of a duplicated fresh id (#407). +#[test] +fn repair_keeps_a_duplicate_it_cannot_reid() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + p.slides.push(p.slides[0].clone()); + repair(&mut p); + assert_eq!(p.slides.len(), 2, "the duplicate stays visible"); + assert_eq!(p.slides[0].id, p.slides[1].id, "no fresh id was invented"); +} + +#[test] +fn outline_roundtrip() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + 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}"); +} + +#[test] +fn outline_keeps_leading_dash_text() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other"); + assert_eq!(n, 2); + let o = slides_to_outline(&p); + assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}"); +} + +#[test] +fn outline_export_keeps_multi_paragraph_title_on_one_line() { + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2); + let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder"); + title.text = Some(TextBody::from_text("Line one\nLine two")); + let o = slides_to_outline(&p); + assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n"); + let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + assert_eq!(outline_to_slides(&mut q, &o), 2); +} + +#[test] +fn markdown_headings_and_list_items() { + assert_eq!(md_heading("# Intro"), Some("Intro")); + assert_eq!(md_heading(" ### Deep ### "), Some("Deep")); + assert_eq!(md_heading("## C# and F#"), Some("C# and F#")); + assert_eq!(md_heading("#"), Some("")); + assert_eq!(md_heading("## Ünïcödé"), Some("Ünïcödé")); + for not in ["#5 priority", "####### seven", " # code", "text", "", "é#"] { + assert_eq!(md_heading(not), None, "{not:?}"); + } + assert_eq!(md_list_item("- a"), Some((0, "a"))); + assert_eq!(md_list_item(" * b"), Some((2, "b"))); + assert_eq!(md_list_item("\t+ c"), Some((1, "c"))); + assert_eq!(md_list_item("12) d"), Some((0, "d"))); + for not in ["-x", "1.x", "1", "1234567890. long", "• bullet", "é. x", ""] { + assert_eq!(md_list_item(not), None, "{not:?}"); + } +} + +/// #814: slides_to_outline includes table cell text, which it used to skip. +#[test] +fn outline_includes_table_text() { + use deckcraft_model::table::{Cell, Row, Table}; + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; + body.text = Some(TextBody::from_text("Text box body")); + s.shapes.push(body); + + let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; + for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { + let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); + table.rows.push(Row { height: 30.0, cells }); + } + let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; + tbl.kind = ShapeKind::Table(table); + s.shapes.push(tbl); + p.slides = vec![std::sync::Arc::new(s)]; + + let out = slides_to_outline(&p); + assert!(out.contains("Text box body"), "{out}"); + assert!(out.contains("Cell A1\tCell B1"), "{out}"); + assert!(out.contains("Cell A2\tCell B2"), "{out}"); +} diff --git a/crates/geom/src/preset.rs b/crates/geom/src/preset.rs index 75e729d47..3e3b5af5a 100644 --- a/crates/geom/src/preset.rs +++ b/crates/geom/src/preset.rs @@ -9,2643 +9,16 @@ use kurbo::{Arc, BezPath, Point, Rect, Vec2}; use serde::{Deserialize, Serialize}; +mod build; +mod builder; +mod catalog; +mod shapes; + +pub use build::build; +use builder::{Corner, Dir, Pb, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple, stroke_only, vis}; +pub use catalog::{CATALOG, Category, FillMode, Geometry, Handle, PresetInfo, SubPath, info, is_line_like, mitred_by_default}; + mod extra; -/// 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`. An `angle` handle instead measures the pointer's angle about `origin` -/// (1/60 000 degree, y down, 0° at +x). -#[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, - pub angle: bool, -} - -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, angle: false } - } - /// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`. - fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self { - let t = vis(value / 60000.0, rx, ry).to_radians(); - let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin()); - Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true } - } - /// The adjust value for a pointer at `p` (shape-local space). - pub fn value_at(&self, p: Point) -> f64 { - if self.angle { - let d = p - self.origin; - if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { - return self.min; - } - let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round(); - return if v >= self.max { self.min } else { v }; - } - 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!("lineInv", "Line: Rising", 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!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 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!("pieWedge", "Quarter Wedge", BasicShapes, []), - p!("chord", "Chord", BasicShapes, [2700000, 16200000]), - p!("teardrop", "Teardrop", BasicShapes, [100000]), - p!("frame", "Frame", BasicShapes, [12500]), - p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []), - p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []), - p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []), - 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!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]), - p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]), - p!("funnel", "Funnel", 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!("chartX", "Chart Marker: Cross Lines", BasicShapes, []), - p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []), - p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []), - 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!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]), - p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]), - p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]), - p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]), - 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!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]), - p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]), - p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]), - p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]), - 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!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]), - p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]), - p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]), - 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!("flowChartOfflineStorage", "Flowchart: Offline Storage", 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!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]), - p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]), - p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]), - 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!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), - p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), - p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]), - p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), - p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), - p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]), - p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), - p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), - p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]), - p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), - p!( - "accentBorderCallout3", - "Callout: Double Bent Line with Border and Accent Bar", - Callouts, - [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333] - ), - 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) -} - -/// Whether PowerPoint joins this preset's outline with sharp (miter) corners when the line names -/// no join (it rounds them otherwise). Observed per preset on its export: the plus, the right, -/// up and down block arrows (not the left one), chevrons, stars from five points up, the -/// equation shapes, curved ribbons and the action buttons are mitred. -pub fn mitred_by_default(name: &str) -> bool { - matches!( - name, - "plus" - | "rightArrow" - | "upArrow" - | "downArrow" - | "upDownArrow" - | "leftUpArrow" - | "bentUpArrow" - | "stripedRightArrow" - | "notchedRightArrow" - | "chevron" - | "leftArrowCallout" - | "leftRightArrowCallout" - | "upDownArrowCallout" - | "quadArrowCallout" - | "mathPlus" - | "mathMinus" - | "mathMultiply" - | "mathEqual" - | "mathNotEqual" - | "star5" - | "star6" - | "star8" - | "star10" - | "star12" - | "star16" - | "star24" - | "star32" - | "ellipseRibbon" - | "ellipseRibbon2" - ) || name.starts_with("actionButton") -} - -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 - } - /// A DrawingML `arcTo`: angles are where the ray from the ellipse's centre at that angle - /// meets it (not the ellipse's parameter), so elliptical arcs start and end where the - /// standard's shapes expect. - fn arc_spec(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { - let par = |a: f64| { - let a = a.to_radians(); - (wr * a.sin()).atan2(hr * a.cos()) - }; - let t0 = par(st); - let mut d = par(st + sw) - t0; - let tau = std::f64::consts::TAU; - if sw.abs() >= 360.0 { - d = tau.copysign(sw); - } else { - while sw > 0.0 && d < 0.0 { - d += tau; - } - while sw < 0.0 && d > 0.0 { - d -= tau; - } - } - self.arc(wr, hr, t0.to_degrees(), d.to_degrees()) - } - 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() -} - -/// An n-pointed star as ECMA-376 draws it: points on an ellipse around (hc, vc · vf) with radii -/// half the box stretched by the preset's `hf` / `vf` (so a five-pointed star fills its box and -/// looks regular in a square), the inner corners `inner` of the way out. -fn star(n: usize, w: f64, h: f64, inner: f64) -> Vec<(f64, f64)> { - let (hf, vf) = match n { - 5 => (1.05146, 1.10557), - 6 => (1.15470, 1.0), - 7 => (1.02572, 1.05210), - 10 => (1.05146, 1.0), - _ => (1.0, 1.0), - }; - let (rx, ry, cy) = (w / 2.0 * hf, h / 2.0 * vf, h / 2.0 * vf); - 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((w / 2.0 + r * rx * a.cos(), cy + r * ry * a.sin())); - } - pts -} - -/// The text rectangle ECMA-376's presetShapeDefinitions give a star (`inner` is the adjust value -/// over 50000), where known: star4's is the square inside its inner points, star5's spans the -/// inner pentagon from its upper side points to its bottom inner point. -fn star_text_rect(n: usize, w: f64, h: f64, inner: f64) -> Option { - let (hc, vc) = (w / 2.0, h / 2.0); - match n { - 4 => { - let (dx, dy) = (hc * inner * 45f64.to_radians().cos(), vc * inner * 45f64.to_radians().sin()); - Some(Rect::new(hc - dx, vc - dy, hc + dx, vc + dy)) - } - 5 => { - let (swd2, shd2, svc) = (hc * 1.05146, vc * 1.10557, vc * 1.10557); - let (iwd2, ihd2) = (swd2 * inner, shd2 * inner); - let sdx1 = iwd2 * 18f64.to_radians().cos(); - let sdy1 = ihd2 * 54f64.to_radians().sin(); - Some(Rect::new(hc - sdx1, svc - sdy1, hc + sdx1, svc + ihd2)) - } - _ => None, - } -} - -#[derive(Clone, Copy)] -enum Corner { - Square, - 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) } -} - -/// Points given in 21600ths of the box (as ECMA-376 defines many fixed shapes), scaled to it. -fn spec_pts(pts: &[(i32, i32)], w: f64, h: f64) -> Vec<(f64, f64)> { - pts.iter().map(|(x, y)| (w * *x as f64 / 21600.0, h * *y as f64 / 21600.0)).collect() -} - -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" => { - // The disk's ends are a third of its height (ECMA-376: ellipses of radius h/6). - let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 100000.0 / 3.0 * h / ss.max(1e-9) }; - 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" => { - // ECMA-376: end ellipses of radius w/6 across. - let r = w / 6.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(); - let mut g = simple(b.take(), full, w, h); - let c = Point::new(rx, ry); - g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0))); - // Thickness: slides inward from the outer edge along the end angle's ray. - let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin()); - let u = (c - pe).normalize(); - let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO }; - g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0)); - g - } - "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); - let mut g = 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) } - } - }; - let c = Point::new(rx, ry); - g.handles.push(Handle::angle(0, c, rx, ry, a(0))); - g.handles.push(Handle::angle(1, c, rx, ry, a(1))); - g - } - "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" => { - // ECMA-376 heart: two cubic curves from the notch at the top to the point. - let (dx1, dx2) = (w * 49.0 / 48.0, w * 10.0 / 48.0); - let y1 = -h / 3.0; - b.m(w / 2.0, h / 4.0) - .c(w / 2.0 + dx2, y1, w / 2.0 + dx1, h / 4.0, w / 2.0, h) - .c(w / 2.0 - dx1, h / 4.0, w / 2.0 - dx2, y1, w / 2.0, h / 4.0) - .z(); - simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h) - } - "lightningBolt" => { - // ECMA-376 lightningBolt, in 21600ths of the box. - let pts = spec_pts( - &[ - (8472, 0), - (12860, 6080), - (11050, 6797), - (16577, 12007), - (14767, 12877), - (21600, 21600), - (10012, 14915), - (12222, 13987), - (5022, 9705), - (7602, 8382), - (0, 3890), - ], - w, - h, - ); - simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h) - } - "sun" => { - // ECMA-376 sun: eight triangular rays (their bases at fixed fractions of the core's - // margin) around a circle of radius 50% - adj. - let a = a(0).clamp(12500.0, 46875.0); - let g0 = 50000.0 - a; - let (g1, g2) = (g0 * 30274.0 / 32768.0, g0 * 12540.0 / 32768.0); - let (g10, g11) = ((50000.0 - g1) * 0.75, (50000.0 - g2) * 0.75); - let (g12, g13, g14) = (g10 + 3662.0, g11 + 3662.0, g11 + 12500.0); - let (g15, g16, g17, g18) = (100000.0 - g10, 100000.0 - g12, 100000.0 - g13, 100000.0 - g14); - let x = |g: f64| w * g / 100000.0; - let y = |g: f64| h * g / 100000.0; - let (ox1, oy1, ox2, oy2) = (w * 18436.0 / 21600.0, h * 3163.0 / 21600.0, w * 3163.0 / 21600.0, h * 18436.0 / 21600.0); - let (hc, vc) = (w / 2.0, h / 2.0); - b.poly(&[(w, vc), (x(g15), y(g18)), (x(g15), y(g14))]); - b.poly(&[(ox1, oy1), (x(g16), y(g13)), (x(g17), y(g12))]); - b.poly(&[(hc, 0.0), (x(g18), y(g10)), (x(g14), y(g10))]); - b.poly(&[(ox2, oy1), (x(g13), y(g12)), (x(g12), y(g13))]); - b.poly(&[(0.0, vc), (x(g10), y(g14)), (x(g10), y(g18))]); - b.poly(&[(ox2, oy2), (x(g12), y(g17)), (x(g13), y(g16))]); - b.poly(&[(hc, h), (x(g14), y(g15)), (x(g18), y(g15))]); - b.poly(&[(ox1, oy2), (x(g17), y(g16)), (x(g16), y(g17))]); - b.ellipse(hc, vc, x(g0), y(g0)); - let g7 = g0 * 23170.0 / 32768.0; - let mut g = simple(b.take(), Rect::new(x(50000.0 - g7), y(50000.0 - g7), x(50000.0 + g7), y(50000.0 + g7)), w, h); - g.handles.push(Handle::new(0, Point::new(0.0, vc), Vec2::new(w / 100000.0, 0.0), a, 12500.0, 46875.0)); - g - } - "moon" => { - // ECMA-376 moon: the outer edge is the left half of an ellipse w × h/2 centred on the - // right edge; the inner edge an arc through the corners and (adj · w, middle) of a - // larger ellipse (rx = g18, ry = dy1) whose centre lies right of that point. - let k = a(0).clamp(0.0, 87500.0) / 100000.0; - let ssf = ss.max(1e-9); - let g0 = ss * k; - let g1 = (ss - g0).max(1e-9); - let g2 = g0 * g0 / g1; - let g3 = ss * ss / g1; - let g5 = 2.0 * g3 - g2; - let g6 = g5 - g0; - let dy1 = (g5 / 2.0 - g0) * (h / 2.0) / ssf; - let (g0w, g6w) = (g0 * w / ssf, g6 * w / ssf); - let rx = (g6w - g0w) / 2.0; - let cx = g0w + rx; - b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0); - if rx > 1e-9 && dy1 > 1e-9 { - // From the bottom-right corner back up to the top-right through (g0w, h/2). - let t0 = ((h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx); - let t1 = (-(h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx) + std::f64::consts::TAU; - let arc = Arc::new(Point::new(cx, h / 2.0), Vec2::new(rx, dy1), t0, t1 - t0, 0.0); - arc.to_cubic_beziers(0.05, |p1, p2, p| { - b.p.curve_to(p1, p2, p); - }); - } - b.z(); - let g12 = g0 * 9598.0 / 32768.0; - let g13 = ss - g12; - let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf; - simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), 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])); - } - // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. - let mut area = path.clone(); - area.close_path(); - Geometry { paths: vec![fill_only(area), 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])); - } - // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. - let mut area = path.clone(); - area.close_path(); - Geometry { paths: vec![fill_only(area), 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" { - // x1 = dy1 · dx2 / hd2 in presetShapeDefinitions, with dy1 = h·k/2: the notch's - // edges run parallel to the head's - let n = dx * k; - 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)] - }; - // Text rectangles as presetShapeDefinitions give them: homePlate's runs to halfway - // along the point; chevron's lies between the notch and the point's base (x1..x2) - // and covers the whole width once those meet or cross (`?: dx x1 l`). - let text = if name == "homePlate" { - Rect::new(0.0, 0.0, w - d / 2.0, h) - } else if w - d > d { - Rect::new(d, 0.0, w - d, h) - } else { - Rect::new(0.0, 0.0, w, h) - }; - let mut g = simple(b.poly(&pts).take(), text, w, h); - g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); - g - } - "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" => { - let u = |x: f64, y: f64| (w * x / 21600.0, h * y / 21600.0); - let (p1, p2, p3, p4) = (u(21600.0, 17322.0), u(10800.0, 17322.0), u(10800.0, 23922.0), u(0.0, 20172.0)); - b.m(0.0, 0.0).l(w, 0.0).l(p1.0, p1.1).c(p2.0, p2.1, p3.0, p3.1, p4.0, p4.1).z(); - simple(b.take(), Rect::new(0.0, 0.0, w, h * 17322.0 / 21600.0), w, h) - } - "flowChartMultidocument" => { - // ECMA-376 multidocument (21600ths): two back pages peeking out above and right of - // the front one; the back pages' edges are outlined only where they show. - let x = |v: f64| w * v / 21600.0; - let y = |v: f64| h * v / 21600.0; - let front = |b: &mut Pb| { - b.m(0.0, y(20782.0)) - .c(x(9298.0), y(23542.0), x(9298.0), y(18022.0), x(18595.0), y(18022.0)) - .l(x(18595.0), y(3675.0)) - .l(0.0, y(3675.0)) - .z(); - }; - let mid = |b: &mut Pb| { - b.m(x(1532.0), y(3675.0)).l(x(1532.0), y(1815.0)).l(x(20000.0), y(1815.0)).l(x(20000.0), y(16252.0)); - b.c(x(19298.0), y(16252.0), x(18595.0), y(16352.0), x(18595.0), y(16352.0)); - }; - let back = |b: &mut Pb| { - b.m(x(2972.0), y(1815.0)).l(x(2972.0), 0.0).l(w, 0.0).l(w, y(14392.0)); - b.c(x(20800.0), y(14392.0), x(20000.0), y(14467.0), x(20000.0), y(14467.0)); - }; - let mut area = Pb::new(); - front(&mut area); - mid(&mut area); - area.l(x(18595.0), y(3675.0)).z(); - back(&mut area); - area.l(x(20000.0), y(1815.0)).z(); - let mut outline = Pb::new(); - outline.m(0.0, y(3675.0)).l(x(18595.0), y(3675.0)).l(x(18595.0), y(18022.0)); - outline.c(x(9298.0), y(18022.0), x(9298.0), y(23542.0), 0.0, y(20782.0)).z(); - mid(&mut outline); - back(&mut outline); - Geometry { - paths: vec![fill_only(area.take()), stroke_only(outline.take())], - text_rect: Rect::new(0.0, y(3675.0), x(18595.0), y(17322.0)), - 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" => { - // ECMA-376: each long edge is two half-ellipses a quarter of the width wide and a - // tenth of the height deep, one bulging in and one out. - let (rx, ry) = (w / 4.0, h / 10.0); - b.m(0.0, ry).arc(rx, ry, 180.0, -180.0).arc(rx, ry, 180.0, 180.0).l(w, h - ry).arc(rx, ry, 0.0, -180.0).arc(rx, ry, 0.0, 180.0).z(); - simple(b.take(), Rect::new(0.0, h / 5.0, 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 text = star_text_rect(n, w, h, inner).unwrap_or_else(|| full.inset(-ss * (0.5 - inner * 0.5) * 0.5)); - let mut g = simple(b.poly(&star(n, w, h, inner)).take(), text, w, h); - g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); - g - } - "irregularSeal1" => { - // ECMA-376 irregularSeal1 ("Explosion: 8 Points"), in 21600ths of the box. - let pts = spec_pts( - &[ - (10800, 5800), - (14522, 0), - (14155, 5325), - (18380, 4457), - (16702, 7315), - (21097, 8137), - (17607, 10475), - (21600, 13290), - (16837, 12942), - (18145, 18095), - (14020, 14457), - (13247, 19737), - (10532, 14935), - (8485, 21600), - (7715, 15627), - (4762, 17617), - (5667, 13937), - (135, 14587), - (3722, 11775), - (0, 8615), - (4627, 7617), - (370, 2295), - (7312, 6320), - (8352, 2295), - ], - w, - h, - ); - simple(b.poly(&pts).take(), Rect::new(w * 4627.0 / 21600.0, h * 6320.0 / 21600.0, w * 16702.0 / 21600.0, h * 13937.0 / 21600.0), w, h) - } - "irregularSeal2" => { - // ECMA-376 irregularSeal2 ("Explosion: 14 Points"), in 21600ths of the box. - let pts = spec_pts( - &[ - (11462, 4342), - (14790, 0), - (14525, 5777), - (18007, 3172), - (16380, 6532), - (21600, 6645), - (16985, 9402), - (18270, 11290), - (16380, 12310), - (18877, 15632), - (14640, 14350), - (14942, 17370), - (12180, 15935), - (11612, 18842), - (9872, 17370), - (8700, 19712), - (7527, 18125), - (4917, 21600), - (4805, 18240), - (1285, 17825), - (3330, 15370), - (0, 12877), - (3935, 11592), - (1172, 8270), - (5372, 7817), - (4502, 3625), - (8550, 6382), - (9722, 1887), - ], - w, - h, - ); - simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h) - } - "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), - "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), - "wave" | "doubleWave" => { - // ECMA-376 wave / doubleWave: the top and bottom edges are cubic waves (one or two - // periods) of amplitude adj1, shifted sideways by adj2. - let a1 = a(0).clamp(0.0, if name == "wave" { 20000.0 } else { 12500.0 }); - let a2 = a(1).clamp(-10000.0, 10000.0); - let y1 = h * a1 / 100000.0; - let dy2 = y1 * 10.0 / 3.0; - let (y2, y3, y4) = (y1 - dy2, y1 + dy2, h - y1); - let (y5, y6) = (y4 - dy2, y4 + dy2); - let of2 = w * a2 / 50000.0; - let dx2 = of2.min(0.0); - let dxr = of2.max(0.0); - let (x2, xr, xl, x10) = (-dx2, w - dxr, dxr, w + dx2); - let text_y = h * a1 / 50000.0; - if name == "wave" { - let dx3 = (dx2 + xr) / 3.0; - let (x3, x7) = (x2 + dx3, xl + dx3); - let (x4, x8) = ((x3 + xr) / 2.0, (x7 + x10) / 2.0); - b.m(x2, y1).c(x3, y2, x4, y3, xr, y1).l(x10, y4).c(x8, y6, x7, y5, xl, y4).z(); - } else { - let (dx3, dx4) = ((dx2 + xr) / 6.0, (dx2 + xr) / 3.0); - let (x3, x4, x5) = (x2 + dx3, x2 + dx4, (x2 + xr) / 2.0); - let x6 = x5 + dx3; - let x7 = (x6 + xr) / 2.0; - let (x10b, x11, x12) = (xl + dx3, xl + dx4, (xl + x10) / 2.0); - let x13 = x12 + dx3; - let x14 = (x13 + x10) / 2.0; - b.m(x2, y1).c(x3, y2, x4, y3, x5, y1).c(x6, y2, x7, y3, xr, y1).l(x10, y4); - b.c(x14, y6, x13, y5, x12, y4).c(x11, y6, x10b, y5, xl, y4).z(); - } - simple(b.take(), Rect::new(x2.max(xl), text_y, xr.min(x10), h - text_y), 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 extra::build(name, w, h, &a), - }; - 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, angle: false } -} - -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(); - // The wedge's base: 2..5 twelfths of the edge on the tip's side of the centre when it is - // before it, 7..10 twelfths when after (presetShapeDefinitions' xg1/xg2, yg1/yg2). - let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (w * 7.0 / 12.0, w * 10.0 / 12.0) }; - let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (h * 7.0 / 12.0, h * 10.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 -} - -/// ECMA-376 cloud outline arcs (radii in 43200ths of the box, angles in 60000ths of a degree, -/// as DrawingML `arcTo` takes them), from the start point (3900, 14370). -const CLOUD_ARCS: [(f64, f64, f64, f64); 11] = [ - (6753.0, 9190.0, -11429249.0, 7426832.0), - (5333.0, 7267.0, -8646143.0, 5396714.0), - (4365.0, 5945.0, -8748475.0, 5983381.0), - (4857.0, 6595.0, -7859164.0, 7034504.0), - (5333.0, 7273.0, -4722533.0, 6541615.0), - (6775.0, 9220.0, -2776035.0, 7816140.0), - (5785.0, 7867.0, 37501.0, 6842000.0), - (6752.0, 9215.0, 1347096.0, 6910353.0), - (7720.0, 10543.0, 3974558.0, 4542661.0), - (4360.0, 5918.0, -16496525.0, 8804134.0), - (4345.0, 5945.0, -14809710.0, 9151131.0), -]; - -/// The cloud's creases: short arcs inside the outline, each from its own start point. -const CLOUD_CREASES: [(f64, f64, f64, f64, f64, f64); 11] = [ - (4693.0, 26177.0, 4345.0, 5945.0, 5204520.0, 1585770.0), - (6928.0, 34899.0, 4360.0, 5918.0, 4416628.0, 686848.0), - (16478.0, 39090.0, 6752.0, 9215.0, 8257449.0, 844866.0), - (28827.0, 34751.0, 6752.0, 9215.0, 387196.0, 959901.0), - (34129.0, 22954.0, 5785.0, 7867.0, -4217541.0, 4255042.0), - (41798.0, 15354.0, 5333.0, 7273.0, 1819082.0, 1665090.0), - (38324.0, 5426.0, 4857.0, 6595.0, -824660.0, 891534.0), - (29078.0, 3952.0, 4857.0, 6595.0, -8950887.0, 1091722.0), - (22141.0, 4720.0, 4365.0, 5945.0, -9809656.0, 1061181.0), - (14000.0, 5192.0, 6753.0, 9190.0, -4002417.0, 739161.0), - (4127.0, 15789.0, 6753.0, 9190.0, 9459261.0, 711490.0), -]; - -fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { - // Drawn in the path's own 43200-unit square, then stretched to the box (as DrawingML lays out - // a path with its own w and h), so the arcs keep their shape's proportions at any aspect. - let to_box = kurbo::Affine::scale_non_uniform(w / 43200.0, h / 43200.0); - let deg = |v: f64| v / 60000.0; - let mut b = Pb::new(); - b.m(3900.0, 14370.0); - for (wr, hr, st, sw) in CLOUD_ARCS { - b.arc_spec(wr, hr, deg(st), deg(sw)); - } - b.z(); - let mut creases = Pb::new(); - for (x, y, wr, hr, st, sw) in CLOUD_CREASES { - creases.m(x, y).arc_spec(wr, hr, deg(st), deg(sw)); - } - let mut outline = b.take(); - outline.apply_affine(to_box); - let mut paths = vec![fill(outline)]; - if name == "cloud" { - let mut c = creases.take(); - c.apply_affine(to_box); - paths.push(stroke_only(c)); - } - let text_rect = Rect::new(w * 2977.0 / 21600.0, h * 3262.0 / 21600.0, w * 17087.0 / 21600.0, h * 17337.0 / 21600.0); - 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) } -} - -/// The equation shapes, by ECMA-376's formulas (checked against PowerPoint's outlines at several -/// sizes and adjust values). Their bars span 73.49% of the width; the plus and multiply bars are -/// `adj1` of the shorter side thick, the others' `adj1` of the height. -fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { - let pin = |lo: f64, v: f64, hi: f64| v.max(lo).min(hi.max(lo)); - let ss = w.min(h); - let (hc, vc) = (w / 2.0, h / 2.0); - let dx1 = w * 73490.0 / 200000.0; - let (x1, x2) = (hc - dx1, hc + dx1); - let mut b = Pb::new(); - let text = match name { - "mathPlus" => { - let a = pin(0.0, a0, 73490.0); - let dy1 = h * 73490.0 / 200000.0; - let dx2 = ss * a / 200000.0; - let (y1, y2, y3, y4) = (vc - dy1, vc - dx2, vc + dx2, vc + dy1); - let (xa, xb) = (hc - dx2, hc + dx2); - b.poly(&[(x1, y2), (xa, y2), (xa, y1), (xb, y1), (xb, y2), (x2, y2), (x2, y3), (xb, y3), (xb, y4), (xa, y4), (xa, y3), (x1, y3)]); - Rect::new(x1, y2, x2, y3) - } - "mathMinus" => { - let dy1 = h * pin(0.0, a0, 100000.0) / 200000.0; - b.poly(&[(x1, vc - dy1), (x2, vc - dy1), (x2, vc + dy1), (x1, vc + dy1)]); - Rect::new(x1, vc - dy1, x2, vc + dy1) - } - "mathEqual" => { - let a = pin(0.0, a0, 36745.0); - let g = pin(0.0, a1, 100000.0 - 2.0 * a); - let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); - let (y2, y3) = (vc - dy2, vc + dy2); - let (y1, y4) = (y2 - dy1, y3 + dy1); - b.poly(&[(x1, y1), (x2, y1), (x2, y2), (x1, y2)]); - b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); - Rect::new(x1, y1, x2, y4) - } - "mathNotEqual" => { - let a = pin(0.0, a0, 50000.0); - let cr_ang = pin(4200000.0, a1, 6600000.0); - let g = pin(0.0, a2, 100000.0 - 2.0 * a); - let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); - let hd2 = (h / 2.0).max(1e-9); - let (y2, y3) = (vc - dy2, vc + dy2); - let (y1, y4) = (y2 - dy1, y3 + dy1); - let cadj2 = cr_ang - 5400000.0; - let xadj2 = hd2 * (cadj2 / 60000.0).to_radians().tan(); - let len = xadj2.hypot(hd2); - let bhw = len * dy1 / hd2; - let x7 = hc + xadj2 - bhw / 2.0; - let at = |y: f64| x7 - xadj2 * y / hd2; - let (x6, x5, x4, x3) = (at(y1), at(y2), at(y3), at(y4)); - let rx7 = x7 + bhw; - let dx7 = dy1 * hd2 / len; - let dy3 = dy1 * xadj2 / len; - let (rx, lx, ry, ly) = if cadj2 > 0.0 { (x7 + dx7, x7, dy3, 0.0) } else { (rx7, rx7 - dx7, 0.0, -dy3) }; - b.poly(&[ - (x1, y1), - (x6, y1), - (lx, ly), - (rx, ry), - (x6 + bhw, y1), - (x2, y1), - (x2, y2), - (x5 + bhw, y2), - (x4 + bhw, y3), - (x2, y3), - (x2, y4), - (x3 + bhw, y4), - (w - lx, h - ly), - (w - rx, h - ry), - (x3, y4), - (x1, y4), - (x1, y3), - (x4, y3), - (x5, y2), - (x1, y2), - ]); - Rect::new(x1, y1, x2, y4) - } - "mathDivide" => { - let a = pin(1000.0, a0, 36745.0); - let max3 = ((73490.0 - a) / 4.0).min(36745.0 * w / h.max(1e-9)); - let r3 = pin(1000.0, a2, max3); - let g = pin(0.0, a1, 73490.0 - 4.0 * r3 - a); - let dy1 = h * a / 200000.0; - let yg = h * g / 100000.0; - let rad = h * r3 / 100000.0; - let (y3, y4) = (vc - dy1, vc + dy1); - let y2 = y3 - (yg + rad); - let y1 = y2 - rad; - b.ellipse(hc, y1 + rad, rad, rad); - b.ellipse(hc, h - y1 - rad, rad, rad); - b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); - Rect::new(x1, y3, x2, y4) - } - _ => { - // mathMultiply: an X whose arms run along the box's diagonals. - let th = ss * pin(0.0, a0, 51965.0) / 100000.0; - let ang = h.atan2(w); - let (sa, ca, ta) = (ang.sin(), ang.cos(), ang.tan().max(1e-9)); - let lm = w.hypot(h) * (1.0 - 51965.0 / 100000.0); - let (xm, ym) = (ca * lm / 2.0, sa * lm / 2.0); - let (dxam, dyam) = (sa * th / 2.0, ca * th / 2.0); - let (xa, ya, xb, yb) = (xm - dxam, ym + dyam, xm + dxam, ym - dyam); - let yc = (hc - xb) * ta + yb; - let (xd, xe) = (w - xb, w - xa); - let xfe = (vc - ya) / ta; - let (xf, xl) = (xe - xfe, xa + xfe); - let (yg, yh, yi) = (h - ya, h - yb, h - yc); - b.poly(&[(xa, ya), (xb, yb), (hc, yc), (xd, yb), (xe, ya), (xf, vc), (xe, yg), (xd, yh), (hc, yi), (xb, yh), (xa, yg), (xl, vc)]); - Rect::new(xa, yb, xe, yh) - } - }; - Geometry { paths: vec![fill(b.take())], text_rect: text, 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 partial_circle_angle_handles_map_back() { - // Start 0° sits at the right of the ellipse, end 270° at its top. - for name in ["pie", "chord", "arc"] { - let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); - assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); - let (s, e) = (&g.handles[0], &g.handles[1]); - assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); - assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); - assert_eq!(s.value_at(s.pos), 0.0, "{name}"); - assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); - // Dragging to the bottom of the ellipse gives 90°. - assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); - // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 - // ellipse at offset (√2000, √2000) from the centre. - let k = 2000f64.sqrt(); - let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); - let (s, e) = (&g.handles[0], &g.handles[1]); - assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); - assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); - for (deg, h) in [(45.0, s), (135.0, e)] { - let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); - assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); - assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); - } - } - for deg in [45.0, 135.0, 200.0, 315.0] { - let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); - let start = g.handles.iter().find(|h| h.index == 0).unwrap(); - // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. - let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); - assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); - let d = start.pos - Point::new(100.0, 50.0); - assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); - assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); - } - let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); - let start = g.handles.iter().find(|h| h.index == 0).unwrap(); - assert_eq!(start.value_at(start.pos), 10800000.0); - let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); - assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); - } - - #[test] - fn presets_with_adjust_values_expose_handles() { - // Presets whose adjust values have no handle of their own yet. - const NO_HANDLES: &[&str] = &[ - "curvedConnector3", - "snip2SameRect", - "snip2DiagRect", - "snipRoundRect", - "round1Rect", - "round2SameRect", - "round2DiagRect", - "teardrop", - "halfFrame", - "corner", - "diagStripe", - "foldedCorner", - "smileyFace", - "moon", - "gear6", - "gear9", - "bracketPair", - "bracePair", - "leftBracket", - "rightBracket", - "leftBrace", - "rightBrace", - "leftRightArrow", - "upDownArrow", - "quadArrow", - "leftRightUpArrow", - "bentArrow", - "uturnArrow", - "curvedRightArrow", - "curvedLeftArrow", - "curvedUpArrow", - "curvedDownArrow", - "leftUpArrow", - "bentUpArrow", - "circularArrow", - "leftCircularArrow", - "leftRightCircularArrow", - "swooshArrow", - "rightArrowCallout", - "downArrowCallout", - "leftArrowCallout", - "upArrowCallout", - "leftRightArrowCallout", - "upDownArrowCallout", - "quadArrowCallout", - "mathPlus", - "mathMinus", - "mathMultiply", - "mathDivide", - "mathEqual", - "mathNotEqual", - "ribbon2", - "ribbon", - "ellipseRibbon2", - "ellipseRibbon", - "leftRightRibbon", - "verticalScroll", - "horizontalScroll", - "wave", - "doubleWave", - "borderCallout1", - "borderCallout2", - "borderCallout3", - "accentCallout1", - "accentCallout2", - "accentCallout3", - "callout1", - "callout2", - "callout3", - "accentBorderCallout1", - "accentBorderCallout2", - "accentBorderCallout3", - ]; - for p in CATALOG { - if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { - continue; - } - let g = build(p.name, 200.0, 100.0, &[]).unwrap(); - assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); - } - } - - #[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()); - } - - fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { - use kurbo::ParamCurveNearest; - g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) - } - - #[test] - fn fixed_shapes_follow_the_standards_coordinates() { - // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. - let (w, h) = (216.0, 108.0); - let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); - let g = build("lightningBolt", w, h, &[]).unwrap(); - for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { - assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); - } - let g = build("irregularSeal1", w, h, &[]).unwrap(); - assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); - let g = build("irregularSeal2", w, h, &[]).unwrap(); - assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); - // The heart's point is the middle of the bottom edge, its notch a quarter down. - let g = build("heart", w, h, &[]).unwrap(); - assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); - let g = build("flowChartDocument", w, h, &[]).unwrap(); - assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); - // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. - let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); - assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); - let g = build("flowChartMultidocument", w, h, &[]).unwrap(); - assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); - } - - #[test] - fn notched_arrow_notch_parallels_the_head() { - // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run - // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). - for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { - let g = build("notchedRightArrow", w, h, &adj).unwrap(); - let notch = g.paths[0].path.elements().iter().find_map(|e| match e { - kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), - _ => None, - }); - assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); - } - } - - #[test] - fn equation_shapes_match_powerpoints_outlines() { - // Corners read off PowerPoint's own outlines of the presets at their default adjusts. - let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); - for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { - assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); - } - let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); - for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { - assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); - } - let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); - assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); - // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches - // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). - let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); - use kurbo::Shape as _; - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); - } - - #[test] - fn sun_and_waves_match_powerpoints_outlines() { - // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). - let k = 216.0; - let g = build("sun", k, k, &[]).unwrap(); - for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); - } - let g = build("wave", k, k, &[]).unwrap(); - for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); - } - // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. - let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); - for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { - assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); - } - } - - #[test] - fn star_text_rectangles_follow_the_standard() { - // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the - // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. - let g = build("star5", 144.0, 144.0, &[]).unwrap(); - let t = g.text_rect; - assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); - assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); - // star4 at 12500: the square through its inner points' 45° corners. - let g = build("star4", 144.0, 144.0, &[]).unwrap(); - assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); - } - - #[test] - fn stars_are_stretched_like_the_standards() { - // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an - // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom - // points reach the box's bottom edge. - let k = 216.0; - let g = build("star5", k, k, &[]).unwrap(); - let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); - let a = 18f64.to_radians(); - assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); - assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); - use kurbo::Shape as _; - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); - // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). - let g = build("star6", k, k, &[]).unwrap(); - let bb = g.paths[0].path.bounding_box(); - assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); - } - - #[test] - fn moon_disk_and_drum_match_powerpoints_outlines() { - let k = 216.0; - // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. - let g = build("moon", k, k, &[]).unwrap(); - for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { - assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); - } - // 300 × 120 at 30%: its inner edge reaches 30% of the width in. - let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); - assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); - // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. - let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); - assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); - let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); - assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); - } - - #[test] - fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { - use kurbo::Shape as _; - for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { - let g = build("cloud", w, h, &[]).unwrap(); - let bb = g.paths[0].path.bounding_box(); - assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); - assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); - assert_eq!(g.paths.len(), 2, "outline and creases"); - assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); - } - } - - #[test] - fn spec_arcs_take_angles_as_directions_from_the_centre() { - // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the - // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. - let mut b = Pb::new(); - b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); - let p = b.take(); - let end = p.elements().last().and_then(|e| match e { - kurbo::PathEl::CurveTo(_, _, q) => Some(*q), - _ => None, - }); - // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. - let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); - let c = Point::new(-k, -k); - let q = end.unwrap(); - assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); - } - - #[test] - fn brackets_and_braces_fill_out_to_their_straight_side() { - // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the - // straight side it opens to; only the curve is outlined. - for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { - let g = build(name, 40.0, 100.0, &[]).unwrap(); - let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); - assert_eq!(filled.len(), 1, "{name}"); - assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); - let area = filled[0].path.area().abs(); - assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); - assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); - } - } - - /// The end points of a path's move/line elements, in order. - fn vertices(p: &BezPath) -> Vec { - p.elements() - .iter() - .filter_map(|e| match e { - kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), - _ => None, - }) - .collect() - } - - #[test] - fn wedge_callout_mirrors_with_its_tip() { - // A tip below and right of the centre draws the mirror image of the same tip on the left; - // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts - // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at - // 7..10 twelfths on the far side. - let (w, h) = (240.0, 120.0); - // Sorted and deduplicated: the outline closes on the corner it starts from, which the - // mirror moves to the opposite corner. - let sorted = |mut v: Vec| { - v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); - v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); - v - }; - for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { - let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); - let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); - let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); - let (a, b) = (sorted(a), sorted(b)); - assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); - for (p, q) in a.iter().zip(&b) { - assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); - } - } - // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. - let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); - for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { - assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); - } - } -} +mod tests; diff --git a/crates/geom/src/preset/build.rs b/crates/geom/src/preset/build.rs new file mode 100644 index 000000000..95b833efd --- /dev/null +++ b/crates/geom/src/preset/build.rs @@ -0,0 +1,1204 @@ +use super::builder::{ + Corner, Dir, Pb, arrow, corner_rect, double_arrow, ellipse_sites, fill, fill_eo, fill_only, len_thick, mid_sites, orient, polygon_regular, shade, + simple, spec_pts, star, star_text_rect, stroke_only, tip_handle, vis, +}; +use super::catalog::{FillMode, Geometry, Handle, info}; +use super::shapes::{action_button, cloud, math, ribbon, scroll, wedge_rect}; +use super::{Arc, BezPath, Point, Rect, Vec2, extra}; + +/// Size and adjust values shared by every preset-shape builder. +struct Ctx<'a> { + name: &'a str, + w: f64, + h: f64, + adj: &'a [f64], + defaults: &'a [f64], +} + +impl Ctx<'_> { + /// Adjust value `i`: the caller's value, else the preset default, else 0 (non-finite values become 0). + fn a(&self, i: usize) -> f64 { + let v = self.adj.get(i).copied().or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0); + if v.is_finite() { v } else { 0.0 } + } +} + +/// 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 defaults = info(name).map(|i| i.defaults).unwrap_or(&[]); + let c = Ctx { name, w: w.max(0.0), h: h.max(0.0), adj, defaults }; + rect_and_triangle_shapes(&c) + .or_else(|| polygon_and_symbol_shapes(&c)) + .or_else(|| bracket_and_arrow_shapes(&c)) + .or_else(|| flowchart_and_star_shapes(&c)) + .or_else(|| callout_wave_and_connector_shapes(&c)) + .or_else(|| extra::build(name, c.w, c.h, &|i| c.a(i))) +} + +fn rect_and_triangle_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.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), + } + } + _ => return None, + }; + Some(g) +} + +fn polygon_and_symbol_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "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" => { + // The disk's ends are a third of its height (ECMA-376: ellipses of radius h/6). + let v = if name == "can" { a(0).clamp(0.0, 50000.0 * h / ss.max(1e-9)) } else { 100000.0 / 3.0 * h / ss.max(1e-9) }; + 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" => { + // ECMA-376: end ellipses of radius w/6 across. + let r = w / 6.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(); + let mut g = simple(b.take(), full, w, h); + let c = Point::new(rx, ry); + g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0))); + // Thickness: slides inward from the outer edge along the end angle's ray. + let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin()); + let u = (c - pe).normalize(); + let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO }; + g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0)); + g + } + "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); + let mut g = 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) } + } + }; + let c = Point::new(rx, ry); + g.handles.push(Handle::angle(0, c, rx, ry, a(0))); + g.handles.push(Handle::angle(1, c, rx, ry, a(1))); + g + } + "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" => { + // ECMA-376 heart: two cubic curves from the notch at the top to the point. + let (dx1, dx2) = (w * 49.0 / 48.0, w * 10.0 / 48.0); + let y1 = -h / 3.0; + b.m(w / 2.0, h / 4.0) + .c(w / 2.0 + dx2, y1, w / 2.0 + dx1, h / 4.0, w / 2.0, h) + .c(w / 2.0 - dx1, h / 4.0, w / 2.0 - dx2, y1, w / 2.0, h / 4.0) + .z(); + simple(b.take(), Rect::new(w * 0.2, h * 0.25, w * 0.8, h * 0.7), w, h) + } + "lightningBolt" => { + // ECMA-376 lightningBolt, in 21600ths of the box. + let pts = spec_pts( + &[ + (8472, 0), + (12860, 6080), + (11050, 6797), + (16577, 12007), + (14767, 12877), + (21600, 21600), + (10012, 14915), + (12222, 13987), + (5022, 9705), + (7602, 8382), + (0, 3890), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 8757.0 / 21600.0, h * 7637.0 / 21600.0, w * 13917.0 / 21600.0, h * 12963.0 / 21600.0), w, h) + } + "sun" => { + // ECMA-376 sun: eight triangular rays (their bases at fixed fractions of the core's + // margin) around a circle of radius 50% - adj. + let a = a(0).clamp(12500.0, 46875.0); + let g0 = 50000.0 - a; + let (g1, g2) = (g0 * 30274.0 / 32768.0, g0 * 12540.0 / 32768.0); + let (g10, g11) = ((50000.0 - g1) * 0.75, (50000.0 - g2) * 0.75); + let (g12, g13, g14) = (g10 + 3662.0, g11 + 3662.0, g11 + 12500.0); + let (g15, g16, g17, g18) = (100000.0 - g10, 100000.0 - g12, 100000.0 - g13, 100000.0 - g14); + let x = |g: f64| w * g / 100000.0; + let y = |g: f64| h * g / 100000.0; + let (ox1, oy1, ox2, oy2) = (w * 18436.0 / 21600.0, h * 3163.0 / 21600.0, w * 3163.0 / 21600.0, h * 18436.0 / 21600.0); + let (hc, vc) = (w / 2.0, h / 2.0); + b.poly(&[(w, vc), (x(g15), y(g18)), (x(g15), y(g14))]); + b.poly(&[(ox1, oy1), (x(g16), y(g13)), (x(g17), y(g12))]); + b.poly(&[(hc, 0.0), (x(g18), y(g10)), (x(g14), y(g10))]); + b.poly(&[(ox2, oy1), (x(g13), y(g12)), (x(g12), y(g13))]); + b.poly(&[(0.0, vc), (x(g10), y(g14)), (x(g10), y(g18))]); + b.poly(&[(ox2, oy2), (x(g12), y(g17)), (x(g13), y(g16))]); + b.poly(&[(hc, h), (x(g14), y(g15)), (x(g18), y(g15))]); + b.poly(&[(ox1, oy2), (x(g17), y(g16)), (x(g16), y(g17))]); + b.ellipse(hc, vc, x(g0), y(g0)); + let g7 = g0 * 23170.0 / 32768.0; + let mut g = simple(b.take(), Rect::new(x(50000.0 - g7), y(50000.0 - g7), x(50000.0 + g7), y(50000.0 + g7)), w, h); + g.handles.push(Handle::new(0, Point::new(0.0, vc), Vec2::new(w / 100000.0, 0.0), a, 12500.0, 46875.0)); + g + } + "moon" => { + // ECMA-376 moon: the outer edge is the left half of an ellipse w × h/2 centred on the + // right edge; the inner edge an arc through the corners and (adj · w, middle) of a + // larger ellipse (rx = g18, ry = dy1) whose centre lies right of that point. + let k = a(0).clamp(0.0, 87500.0) / 100000.0; + let ssf = ss.max(1e-9); + let g0 = ss * k; + let g1 = (ss - g0).max(1e-9); + let g2 = g0 * g0 / g1; + let g3 = ss * ss / g1; + let g5 = 2.0 * g3 - g2; + let g6 = g5 - g0; + let dy1 = (g5 / 2.0 - g0) * (h / 2.0) / ssf; + let (g0w, g6w) = (g0 * w / ssf, g6 * w / ssf); + let rx = (g6w - g0w) / 2.0; + let cx = g0w + rx; + b.m(w, 0.0).arc(w, h / 2.0, 270.0, -180.0); + if rx > 1e-9 && dy1 > 1e-9 { + // From the bottom-right corner back up to the top-right through (g0w, h/2). + let t0 = ((h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx); + let t1 = (-(h / 2.0) / dy1).clamp(-1.0, 1.0).atan2((w - cx) / rx) + std::f64::consts::TAU; + let arc = Arc::new(Point::new(cx, h / 2.0), Vec2::new(rx, dy1), t0, t1 - t0, 0.0); + arc.to_cubic_beziers(0.05, |p1, p2, p| { + b.p.curve_to(p1, p2, p); + }); + } + b.z(); + let g12 = g0 * 9598.0 / 32768.0; + let g13 = ss - g12; + let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf; + simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), w, h) + } + _ => return None, + }; + Some(g) +} + +fn bracket_and_arrow_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "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])); + } + // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. + let mut area = path.clone(); + area.close_path(); + Geometry { paths: vec![fill_only(area), 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])); + } + // Filled (when the shape has a fill) out to the straight side, as PowerPoint does. + let mut area = path.clone(); + area.close_path(); + Geometry { paths: vec![fill_only(area), 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" { + // x1 = dy1 · dx2 / hd2 in presetShapeDefinitions, with dy1 = h·k/2: the notch's + // edges run parallel to the head's + let n = dx * k; + 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)] + }; + // Text rectangles as presetShapeDefinitions give them: homePlate's runs to halfway + // along the point; chevron's lies between the notch and the point's base (x1..x2) + // and covers the whole width once those meet or cross (`?: dx x1 l`). + let text = if name == "homePlate" { + Rect::new(0.0, 0.0, w - d / 2.0, h) + } else if w - d > d { + Rect::new(d, 0.0, w - d, h) + } else { + Rect::new(0.0, 0.0, w, h) + }; + let mut g = simple(b.poly(&pts).take(), text, w, h); + g.handles.push(Handle::new(0, Point::new(w, 0.0), Vec2::new(-ss / 1e5, 0.0), v, 0.0, 100000.0 * w / ss.max(1e-9))); + g + } + "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)), + _ => return None, + }; + Some(g) +} + +fn flowchart_and_star_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "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" => { + let u = |x: f64, y: f64| (w * x / 21600.0, h * y / 21600.0); + let (p1, p2, p3, p4) = (u(21600.0, 17322.0), u(10800.0, 17322.0), u(10800.0, 23922.0), u(0.0, 20172.0)); + b.m(0.0, 0.0).l(w, 0.0).l(p1.0, p1.1).c(p2.0, p2.1, p3.0, p3.1, p4.0, p4.1).z(); + simple(b.take(), Rect::new(0.0, 0.0, w, h * 17322.0 / 21600.0), w, h) + } + "flowChartMultidocument" => { + // ECMA-376 multidocument (21600ths): two back pages peeking out above and right of + // the front one; the back pages' edges are outlined only where they show. + let x = |v: f64| w * v / 21600.0; + let y = |v: f64| h * v / 21600.0; + let front = |b: &mut Pb| { + b.m(0.0, y(20782.0)) + .c(x(9298.0), y(23542.0), x(9298.0), y(18022.0), x(18595.0), y(18022.0)) + .l(x(18595.0), y(3675.0)) + .l(0.0, y(3675.0)) + .z(); + }; + let mid = |b: &mut Pb| { + b.m(x(1532.0), y(3675.0)).l(x(1532.0), y(1815.0)).l(x(20000.0), y(1815.0)).l(x(20000.0), y(16252.0)); + b.c(x(19298.0), y(16252.0), x(18595.0), y(16352.0), x(18595.0), y(16352.0)); + }; + let back = |b: &mut Pb| { + b.m(x(2972.0), y(1815.0)).l(x(2972.0), 0.0).l(w, 0.0).l(w, y(14392.0)); + b.c(x(20800.0), y(14392.0), x(20000.0), y(14467.0), x(20000.0), y(14467.0)); + }; + let mut area = Pb::new(); + front(&mut area); + mid(&mut area); + area.l(x(18595.0), y(3675.0)).z(); + back(&mut area); + area.l(x(20000.0), y(1815.0)).z(); + let mut outline = Pb::new(); + outline.m(0.0, y(3675.0)).l(x(18595.0), y(3675.0)).l(x(18595.0), y(18022.0)); + outline.c(x(9298.0), y(18022.0), x(9298.0), y(23542.0), 0.0, y(20782.0)).z(); + mid(&mut outline); + back(&mut outline); + Geometry { + paths: vec![fill_only(area.take()), stroke_only(outline.take())], + text_rect: Rect::new(0.0, y(3675.0), x(18595.0), y(17322.0)), + 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" => { + // ECMA-376: each long edge is two half-ellipses a quarter of the width wide and a + // tenth of the height deep, one bulging in and one out. + let (rx, ry) = (w / 4.0, h / 10.0); + b.m(0.0, ry).arc(rx, ry, 180.0, -180.0).arc(rx, ry, 180.0, 180.0).l(w, h - ry).arc(rx, ry, 0.0, -180.0).arc(rx, ry, 0.0, 180.0).z(); + simple(b.take(), Rect::new(0.0, h / 5.0, 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 text = star_text_rect(n, w, h, inner).unwrap_or_else(|| full.inset(-ss * (0.5 - inner * 0.5) * 0.5)); + let mut g = simple(b.poly(&star(n, w, h, inner)).take(), text, w, h); + g.handles.push(Handle::new(0, Point::new(w / 2.0, h / 2.0), Vec2::new(0.0, -h / 2.0 / 50000.0), v, 0.0, 50000.0)); + g + } + "irregularSeal1" => { + // ECMA-376 irregularSeal1 ("Explosion: 8 Points"), in 21600ths of the box. + let pts = spec_pts( + &[ + (10800, 5800), + (14522, 0), + (14155, 5325), + (18380, 4457), + (16702, 7315), + (21097, 8137), + (17607, 10475), + (21600, 13290), + (16837, 12942), + (18145, 18095), + (14020, 14457), + (13247, 19737), + (10532, 14935), + (8485, 21600), + (7715, 15627), + (4762, 17617), + (5667, 13937), + (135, 14587), + (3722, 11775), + (0, 8615), + (4627, 7617), + (370, 2295), + (7312, 6320), + (8352, 2295), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 4627.0 / 21600.0, h * 6320.0 / 21600.0, w * 16702.0 / 21600.0, h * 13937.0 / 21600.0), w, h) + } + "irregularSeal2" => { + // ECMA-376 irregularSeal2 ("Explosion: 14 Points"), in 21600ths of the box. + let pts = spec_pts( + &[ + (11462, 4342), + (14790, 0), + (14525, 5777), + (18007, 3172), + (16380, 6532), + (21600, 6645), + (16985, 9402), + (18270, 11290), + (16380, 12310), + (18877, 15632), + (14640, 14350), + (14942, 17370), + (12180, 15935), + (11612, 18842), + (9872, 17370), + (8700, 19712), + (7527, 18125), + (4917, 21600), + (4805, 18240), + (1285, 17825), + (3330, 15370), + (0, 12877), + (3935, 11592), + (1172, 8270), + (5372, 7817), + (4502, 3625), + (8550, 6382), + (9722, 1887), + ], + w, + h, + ); + simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h) + } + _ => return None, + }; + Some(g) +} + +fn callout_wave_and_connector_shapes(c: &Ctx) -> Option { + let name = c.name; + let w = c.w; + let h = c.h; + let ss = c.w.min(c.h); + let full = Rect::new(0.0, 0.0, c.w, c.h); + let a = |i: usize| c.a(i); + let mut b = Pb::new(); + let g = match c.name { + "ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)), + "verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)), + "wave" | "doubleWave" => { + // ECMA-376 wave / doubleWave: the top and bottom edges are cubic waves (one or two + // periods) of amplitude adj1, shifted sideways by adj2. + let a1 = a(0).clamp(0.0, if name == "wave" { 20000.0 } else { 12500.0 }); + let a2 = a(1).clamp(-10000.0, 10000.0); + let y1 = h * a1 / 100000.0; + let dy2 = y1 * 10.0 / 3.0; + let (y2, y3, y4) = (y1 - dy2, y1 + dy2, h - y1); + let (y5, y6) = (y4 - dy2, y4 + dy2); + let of2 = w * a2 / 50000.0; + let dx2 = of2.min(0.0); + let dxr = of2.max(0.0); + let (x2, xr, xl, x10) = (-dx2, w - dxr, dxr, w + dx2); + let text_y = h * a1 / 50000.0; + if name == "wave" { + let dx3 = (dx2 + xr) / 3.0; + let (x3, x7) = (x2 + dx3, xl + dx3); + let (x4, x8) = ((x3 + xr) / 2.0, (x7 + x10) / 2.0); + b.m(x2, y1).c(x3, y2, x4, y3, xr, y1).l(x10, y4).c(x8, y6, x7, y5, xl, y4).z(); + } else { + let (dx3, dx4) = ((dx2 + xr) / 6.0, (dx2 + xr) / 3.0); + let (x3, x4, x5) = (x2 + dx3, x2 + dx4, (x2 + xr) / 2.0); + let x6 = x5 + dx3; + let x7 = (x6 + xr) / 2.0; + let (x10b, x11, x12) = (xl + dx3, xl + dx4, (xl + x10) / 2.0); + let x13 = x12 + dx3; + let x14 = (x13 + x10) / 2.0; + b.m(x2, y1).c(x3, y2, x4, y3, x5, y1).c(x6, y2, x7, y3, xr, y1).l(x10, y4); + b.c(x14, y6, x13, y5, x12, y4).c(x11, y6, x10b, y5, xl, y4).z(); + } + simple(b.take(), Rect::new(x2.max(xl), text_y, xr.min(x10), h - text_y), 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) +} diff --git a/crates/geom/src/preset/builder.rs b/crates/geom/src/preset/builder.rs new file mode 100644 index 000000000..701d15242 --- /dev/null +++ b/crates/geom/src/preset/builder.rs @@ -0,0 +1,344 @@ +use super::catalog::{FillMode, Geometry, Handle, SubPath}; +use super::{Arc, BezPath, Point, Rect, Vec2}; + +// ---------- path builder ---------- + +pub(super) struct Pb { + pub(super) p: BezPath, + pub(super) cur: Point, +} + +impl Pb { + pub(super) fn new() -> Self { + Pb { p: BezPath::new(), cur: Point::ZERO } + } + pub(super) fn m(&mut self, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.move_to(self.cur); + self + } + pub(super) fn l(&mut self, x: f64, y: f64) -> &mut Self { + self.cur = Point::new(x, y); + self.p.line_to(self.cur); + self + } + pub(super) 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 + } + pub(super) 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). + pub(super) 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 + } + /// A DrawingML `arcTo`: angles are where the ray from the ellipse's centre at that angle + /// meets it (not the ellipse's parameter), so elliptical arcs start and end where the + /// standard's shapes expect. + pub(super) fn arc_spec(&mut self, wr: f64, hr: f64, st: f64, sw: f64) -> &mut Self { + let par = |a: f64| { + let a = a.to_radians(); + (wr * a.sin()).atan2(hr * a.cos()) + }; + let t0 = par(st); + let mut d = par(st + sw) - t0; + let tau = std::f64::consts::TAU; + if sw.abs() >= 360.0 { + d = tau.copysign(sw); + } else { + while sw > 0.0 && d < 0.0 { + d += tau; + } + while sw < 0.0 && d > 0.0 { + d -= tau; + } + } + self.arc(wr, hr, t0.to_degrees(), d.to_degrees()) + } + pub(super) fn z(&mut self) -> &mut Self { + self.p.close_path(); + self + } + pub(super) 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() + } + pub(super) 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() + } + pub(super) fn take(&mut self) -> BezPath { + std::mem::take(&mut self.p) + } +} + +pub(super) fn fill(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: true, even_odd: false } +} + +pub(super) fn fill_eo(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::Norm, stroke: true, even_odd: true } +} + +pub(super) fn shade(p: BezPath, m: FillMode) -> SubPath { + SubPath { path: p, fill: m, stroke: true, even_odd: false } +} + +pub(super) fn stroke_only(p: BezPath) -> SubPath { + SubPath { path: p, fill: FillMode::None, stroke: true, even_odd: false } +} + +pub(super) 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). +pub(super) fn vis(deg: f64, rx: f64, ry: f64) -> f64 { + let t = deg.to_radians(); + (rx * t.sin()).atan2(ry * t.cos()).to_degrees() +} + +pub(super) 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() +} + +/// An n-pointed star as ECMA-376 draws it: points on an ellipse around (hc, vc · vf) with radii +/// half the box stretched by the preset's `hf` / `vf` (so a five-pointed star fills its box and +/// looks regular in a square), the inner corners `inner` of the way out. +pub(super) fn star(n: usize, w: f64, h: f64, inner: f64) -> Vec<(f64, f64)> { + let (hf, vf) = match n { + 5 => (1.05146, 1.10557), + 6 => (1.15470, 1.0), + 7 => (1.02572, 1.05210), + 10 => (1.05146, 1.0), + _ => (1.0, 1.0), + }; + let (rx, ry, cy) = (w / 2.0 * hf, h / 2.0 * vf, h / 2.0 * vf); + 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((w / 2.0 + r * rx * a.cos(), cy + r * ry * a.sin())); + } + pts +} + +/// The text rectangle ECMA-376's presetShapeDefinitions give a star (`inner` is the adjust value +/// over 50000), where known: star4's is the square inside its inner points, star5's spans the +/// inner pentagon from its upper side points to its bottom inner point. +pub(super) fn star_text_rect(n: usize, w: f64, h: f64, inner: f64) -> Option { + let (hc, vc) = (w / 2.0, h / 2.0); + match n { + 4 => { + let (dx, dy) = (hc * inner * 45f64.to_radians().cos(), vc * inner * 45f64.to_radians().sin()); + Some(Rect::new(hc - dx, vc - dy, hc + dx, vc + dy)) + } + 5 => { + let (swd2, shd2, svc) = (hc * 1.05146, vc * 1.10557, vc * 1.10557); + let (iwd2, ihd2) = (swd2 * inner, shd2 * inner); + let sdx1 = iwd2 * 18f64.to_radians().cos(); + let sdy1 = ihd2 * 54f64.to_radians().sin(); + Some(Rect::new(hc - sdx1, svc - sdy1, hc + sdx1, svc + ihd2)) + } + _ => None, + } +} + +#[derive(Clone, Copy)] +pub(super) enum Corner { + Square, + Round(f64), + Snip(f64), +} + +/// A rectangle with each corner (TL, TR, BR, BL) square, rounded or snipped. +pub(super) 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() +} + +pub(super) 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)] +pub(super) enum Dir { + Right, + Left, + Up, + Down, +} + +/// Map canonical (u along the arrow's length L, v across thickness T) to shape space. +pub(super) 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() +} + +pub(super) 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), + } +} + +pub(super) 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 +} + +pub(super) 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) } +} + +/// Points given in 21600ths of the box (as ECMA-376 defines many fixed shapes), scaled to it. +pub(super) fn spec_pts(pts: &[(i32, i32)], w: f64, h: f64) -> Vec<(f64, f64)> { + pts.iter().map(|(x, y)| (w * *x as f64 / 21600.0, h * *y as f64 / 21600.0)).collect() +} + +pub(super) 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) } +} + +pub(super) 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() +} + +pub(super) 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, angle: false } +} diff --git a/crates/geom/src/preset/catalog.rs b/crates/geom/src/preset/catalog.rs new file mode 100644 index 000000000..ca090f94d --- /dev/null +++ b/crates/geom/src/preset/catalog.rs @@ -0,0 +1,391 @@ +use super::builder::vis; +use super::{BezPath, Deserialize, Point, Rect, Serialize, Vec2}; + +/// 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`. An `angle` handle instead measures the pointer's angle about `origin` +/// (1/60 000 degree, y down, 0° at +x). +#[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, + pub angle: bool, +} + +impl Handle { + pub(super) 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, angle: false } + } + /// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`. + pub(super) fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self { + let t = vis(value / 60000.0, rx, ry).to_radians(); + let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin()); + Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true } + } + /// The adjust value for a pointer at `p` (shape-local space). + pub fn value_at(&self, p: Point) -> f64 { + if self.angle { + let d = p - self.origin; + if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 { + return self.min; + } + let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round(); + return if v >= self.max { self.min } else { v }; + } + 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!("lineInv", "Line: Rising", 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!("nonIsoscelesTrapezoid", "Trapezoid: Uneven Sides", BasicShapes, [25000, 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!("pieWedge", "Quarter Wedge", BasicShapes, []), + p!("chord", "Chord", BasicShapes, [2700000, 16200000]), + p!("teardrop", "Teardrop", BasicShapes, [100000]), + p!("frame", "Frame", BasicShapes, [12500]), + p!("cornerTabs", "Corner Tabs: Triangles", BasicShapes, []), + p!("squareTabs", "Corner Tabs: Squares", BasicShapes, []), + p!("plaqueTabs", "Corner Tabs: Quarter Circles", BasicShapes, []), + 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!("gear6", "Gear: 6 Teeth", BasicShapes, [15000, 3526]), + p!("gear9", "Gear: 9 Teeth", BasicShapes, [10000, 1763]), + p!("funnel", "Funnel", 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!("chartX", "Chart Marker: Cross Lines", BasicShapes, []), + p!("chartStar", "Chart Marker: Star Lines", BasicShapes, []), + p!("chartPlus", "Chart Marker: Plus Lines", BasicShapes, []), + 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!("curvedRightArrow", "Arrow: Curved Right", BlockArrows, [25000, 50000, 25000]), + p!("curvedLeftArrow", "Arrow: Curved Left", BlockArrows, [25000, 50000, 25000]), + p!("curvedUpArrow", "Arrow: Curved Up", BlockArrows, [25000, 50000, 25000]), + p!("curvedDownArrow", "Arrow: Curved Down", BlockArrows, [25000, 50000, 25000]), + 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!("circularArrow", "Arrow: Circular", BlockArrows, [12500, 1142319, 20457681, 10800000, 12500]), + p!("leftCircularArrow", "Arrow: Circular Counter-clockwise", BlockArrows, [12500, -1142319, 1142319, 10800000, 12500]), + p!("leftRightCircularArrow", "Arrow: Circular Two-headed", BlockArrows, [12500, 1142319, 20457681, 11942319, 12500]), + p!("swooshArrow", "Arrow: Swoosh", BlockArrows, [25000, 16667]), + 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!("leftRightArrowCallout", "Callout: Left-Right Arrow", BlockArrows, [25000, 25000, 25000, 48123]), + p!("upDownArrowCallout", "Callout: Up-Down Arrow", BlockArrows, [25000, 25000, 25000, 48123]), + p!("quadArrowCallout", "Callout: Quad Arrow", BlockArrows, [18515, 18515, 18515, 48123]), + 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!("flowChartOfflineStorage", "Flowchart: Offline Storage", 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!("ellipseRibbon2", "Ribbon: Curved and Tilted Up", StarsAndBanners, [25000, 50000, 12500]), + p!("ellipseRibbon", "Ribbon: Curved and Tilted Down", StarsAndBanners, [25000, 50000, 12500]), + p!("leftRightRibbon", "Ribbon: Left-Right Fold", StarsAndBanners, [50000, 50000, 16667]), + 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!("borderCallout2", "Callout: Bent Line", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("borderCallout3", "Callout: Double Bent Line", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("accentCallout1", "Callout: Line with Accent Bar", Callouts, [18750, -8333, 112500, -38333]), + p!("accentCallout2", "Callout: Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("accentCallout3", "Callout: Double Bent Line with Accent Bar", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("callout1", "Callout: Line with No Border", Callouts, [18750, -8333, 112500, -38333]), + p!("callout2", "Callout: Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!("callout3", "Callout: Double Bent Line with No Border", Callouts, [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333]), + p!("accentBorderCallout1", "Callout: Line with Border and Accent Bar", Callouts, [18750, -8333, 112500, -38333]), + p!("accentBorderCallout2", "Callout: Bent Line with Border and Accent Bar", Callouts, [18750, -8333, 18750, -16667, 112500, -46667]), + p!( + "accentBorderCallout3", + "Callout: Double Bent Line with Border and Accent Bar", + Callouts, + [18750, -8333, 18750, -16667, 100000, -16667, 112963, -8333] + ), + 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) +} + +/// Whether PowerPoint joins this preset's outline with sharp (miter) corners when the line names +/// no join (it rounds them otherwise). Observed per preset on its export: the plus, the right, +/// up and down block arrows (not the left one), chevrons, stars from five points up, the +/// equation shapes, curved ribbons and the action buttons are mitred. +pub fn mitred_by_default(name: &str) -> bool { + matches!( + name, + "plus" + | "rightArrow" + | "upArrow" + | "downArrow" + | "upDownArrow" + | "leftUpArrow" + | "bentUpArrow" + | "stripedRightArrow" + | "notchedRightArrow" + | "chevron" + | "leftArrowCallout" + | "leftRightArrowCallout" + | "upDownArrowCallout" + | "quadArrowCallout" + | "mathPlus" + | "mathMinus" + | "mathMultiply" + | "mathEqual" + | "mathNotEqual" + | "star5" + | "star6" + | "star8" + | "star10" + | "star12" + | "star16" + | "star24" + | "star32" + | "ellipseRibbon" + | "ellipseRibbon2" + ) || name.starts_with("actionButton") +} + +pub fn is_line_like(name: &str) -> bool { + matches!(name, "line" | "straightConnector1" | "bentConnector2" | "bentConnector3" | "curvedConnector3" | "arc") + || name.starts_with("bentConnector") + || name.starts_with("curvedConnector") +} diff --git a/crates/geom/src/preset/shapes.rs b/crates/geom/src/preset/shapes.rs new file mode 100644 index 000000000..156475435 --- /dev/null +++ b/crates/geom/src/preset/shapes.rs @@ -0,0 +1,375 @@ +use super::builder::{Corner, Pb, corner_rect, fill, mid_sites, shade, simple, stroke_only, tip_handle}; +use super::catalog::{FillMode, Geometry}; +use super::{Point, Rect}; + +pub(super) 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(); + // The wedge's base: 2..5 twelfths of the edge on the tip's side of the centre when it is + // before it, 7..10 twelfths when after (presetShapeDefinitions' xg1/xg2, yg1/yg2). + let (bx0, bx1) = if tip.x < w / 2.0 { (w * 2.0 / 12.0, w * 5.0 / 12.0) } else { (w * 7.0 / 12.0, w * 10.0 / 12.0) }; + let (by0, by1) = if tip.y < h / 2.0 { (h * 2.0 / 12.0, h * 5.0 / 12.0) } else { (h * 7.0 / 12.0, h * 10.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 +} + +/// ECMA-376 cloud outline arcs (radii in 43200ths of the box, angles in 60000ths of a degree, +/// as DrawingML `arcTo` takes them), from the start point (3900, 14370). +const CLOUD_ARCS: [(f64, f64, f64, f64); 11] = [ + (6753.0, 9190.0, -11429249.0, 7426832.0), + (5333.0, 7267.0, -8646143.0, 5396714.0), + (4365.0, 5945.0, -8748475.0, 5983381.0), + (4857.0, 6595.0, -7859164.0, 7034504.0), + (5333.0, 7273.0, -4722533.0, 6541615.0), + (6775.0, 9220.0, -2776035.0, 7816140.0), + (5785.0, 7867.0, 37501.0, 6842000.0), + (6752.0, 9215.0, 1347096.0, 6910353.0), + (7720.0, 10543.0, 3974558.0, 4542661.0), + (4360.0, 5918.0, -16496525.0, 8804134.0), + (4345.0, 5945.0, -14809710.0, 9151131.0), +]; + +/// The cloud's creases: short arcs inside the outline, each from its own start point. +const CLOUD_CREASES: [(f64, f64, f64, f64, f64, f64); 11] = [ + (4693.0, 26177.0, 4345.0, 5945.0, 5204520.0, 1585770.0), + (6928.0, 34899.0, 4360.0, 5918.0, 4416628.0, 686848.0), + (16478.0, 39090.0, 6752.0, 9215.0, 8257449.0, 844866.0), + (28827.0, 34751.0, 6752.0, 9215.0, 387196.0, 959901.0), + (34129.0, 22954.0, 5785.0, 7867.0, -4217541.0, 4255042.0), + (41798.0, 15354.0, 5333.0, 7273.0, 1819082.0, 1665090.0), + (38324.0, 5426.0, 4857.0, 6595.0, -824660.0, 891534.0), + (29078.0, 3952.0, 4857.0, 6595.0, -8950887.0, 1091722.0), + (22141.0, 4720.0, 4365.0, 5945.0, -9809656.0, 1061181.0), + (14000.0, 5192.0, 6753.0, 9190.0, -4002417.0, 739161.0), + (4127.0, 15789.0, 6753.0, 9190.0, 9459261.0, 711490.0), +]; + +pub(super) fn cloud(name: &str, w: f64, h: f64, a0: f64, a1: f64) -> Geometry { + // Drawn in the path's own 43200-unit square, then stretched to the box (as DrawingML lays out + // a path with its own w and h), so the arcs keep their shape's proportions at any aspect. + let to_box = kurbo::Affine::scale_non_uniform(w / 43200.0, h / 43200.0); + let deg = |v: f64| v / 60000.0; + let mut b = Pb::new(); + b.m(3900.0, 14370.0); + for (wr, hr, st, sw) in CLOUD_ARCS { + b.arc_spec(wr, hr, deg(st), deg(sw)); + } + b.z(); + let mut creases = Pb::new(); + for (x, y, wr, hr, st, sw) in CLOUD_CREASES { + creases.m(x, y).arc_spec(wr, hr, deg(st), deg(sw)); + } + let mut outline = b.take(); + outline.apply_affine(to_box); + let mut paths = vec![fill(outline)]; + if name == "cloud" { + let mut c = creases.take(); + c.apply_affine(to_box); + paths.push(stroke_only(c)); + } + let text_rect = Rect::new(w * 2977.0 / 21600.0, h * 3262.0 / 21600.0, w * 17087.0 / 21600.0, h * 17337.0 / 21600.0); + 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) } +} + +/// The equation shapes, by ECMA-376's formulas (checked against PowerPoint's outlines at several +/// sizes and adjust values). Their bars span 73.49% of the width; the plus and multiply bars are +/// `adj1` of the shorter side thick, the others' `adj1` of the height. +pub(super) fn math(name: &str, w: f64, h: f64, a0: f64, a1: f64, a2: f64) -> Geometry { + let pin = |lo: f64, v: f64, hi: f64| v.max(lo).min(hi.max(lo)); + let ss = w.min(h); + let (hc, vc) = (w / 2.0, h / 2.0); + let dx1 = w * 73490.0 / 200000.0; + let (x1, x2) = (hc - dx1, hc + dx1); + let mut b = Pb::new(); + let text = match name { + "mathPlus" => { + let a = pin(0.0, a0, 73490.0); + let dy1 = h * 73490.0 / 200000.0; + let dx2 = ss * a / 200000.0; + let (y1, y2, y3, y4) = (vc - dy1, vc - dx2, vc + dx2, vc + dy1); + let (xa, xb) = (hc - dx2, hc + dx2); + b.poly(&[(x1, y2), (xa, y2), (xa, y1), (xb, y1), (xb, y2), (x2, y2), (x2, y3), (xb, y3), (xb, y4), (xa, y4), (xa, y3), (x1, y3)]); + Rect::new(x1, y2, x2, y3) + } + "mathMinus" => { + let dy1 = h * pin(0.0, a0, 100000.0) / 200000.0; + b.poly(&[(x1, vc - dy1), (x2, vc - dy1), (x2, vc + dy1), (x1, vc + dy1)]); + Rect::new(x1, vc - dy1, x2, vc + dy1) + } + "mathEqual" => { + let a = pin(0.0, a0, 36745.0); + let g = pin(0.0, a1, 100000.0 - 2.0 * a); + let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); + let (y2, y3) = (vc - dy2, vc + dy2); + let (y1, y4) = (y2 - dy1, y3 + dy1); + b.poly(&[(x1, y1), (x2, y1), (x2, y2), (x1, y2)]); + b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); + Rect::new(x1, y1, x2, y4) + } + "mathNotEqual" => { + let a = pin(0.0, a0, 50000.0); + let cr_ang = pin(4200000.0, a1, 6600000.0); + let g = pin(0.0, a2, 100000.0 - 2.0 * a); + let (dy1, dy2) = (h * a / 100000.0, h * g / 200000.0); + let hd2 = (h / 2.0).max(1e-9); + let (y2, y3) = (vc - dy2, vc + dy2); + let (y1, y4) = (y2 - dy1, y3 + dy1); + let cadj2 = cr_ang - 5400000.0; + let xadj2 = hd2 * (cadj2 / 60000.0).to_radians().tan(); + let len = xadj2.hypot(hd2); + let bhw = len * dy1 / hd2; + let x7 = hc + xadj2 - bhw / 2.0; + let at = |y: f64| x7 - xadj2 * y / hd2; + let (x6, x5, x4, x3) = (at(y1), at(y2), at(y3), at(y4)); + let rx7 = x7 + bhw; + let dx7 = dy1 * hd2 / len; + let dy3 = dy1 * xadj2 / len; + let (rx, lx, ry, ly) = if cadj2 > 0.0 { (x7 + dx7, x7, dy3, 0.0) } else { (rx7, rx7 - dx7, 0.0, -dy3) }; + b.poly(&[ + (x1, y1), + (x6, y1), + (lx, ly), + (rx, ry), + (x6 + bhw, y1), + (x2, y1), + (x2, y2), + (x5 + bhw, y2), + (x4 + bhw, y3), + (x2, y3), + (x2, y4), + (x3 + bhw, y4), + (w - lx, h - ly), + (w - rx, h - ry), + (x3, y4), + (x1, y4), + (x1, y3), + (x4, y3), + (x5, y2), + (x1, y2), + ]); + Rect::new(x1, y1, x2, y4) + } + "mathDivide" => { + let a = pin(1000.0, a0, 36745.0); + let max3 = ((73490.0 - a) / 4.0).min(36745.0 * w / h.max(1e-9)); + let r3 = pin(1000.0, a2, max3); + let g = pin(0.0, a1, 73490.0 - 4.0 * r3 - a); + let dy1 = h * a / 200000.0; + let yg = h * g / 100000.0; + let rad = h * r3 / 100000.0; + let (y3, y4) = (vc - dy1, vc + dy1); + let y2 = y3 - (yg + rad); + let y1 = y2 - rad; + b.ellipse(hc, y1 + rad, rad, rad); + b.ellipse(hc, h - y1 - rad, rad, rad); + b.poly(&[(x1, y3), (x2, y3), (x2, y4), (x1, y4)]); + Rect::new(x1, y3, x2, y4) + } + _ => { + // mathMultiply: an X whose arms run along the box's diagonals. + let th = ss * pin(0.0, a0, 51965.0) / 100000.0; + let ang = h.atan2(w); + let (sa, ca, ta) = (ang.sin(), ang.cos(), ang.tan().max(1e-9)); + let lm = w.hypot(h) * (1.0 - 51965.0 / 100000.0); + let (xm, ym) = (ca * lm / 2.0, sa * lm / 2.0); + let (dxam, dyam) = (sa * th / 2.0, ca * th / 2.0); + let (xa, ya, xb, yb) = (xm - dxam, ym + dyam, xm + dxam, ym - dyam); + let yc = (hc - xb) * ta + yb; + let (xd, xe) = (w - xb, w - xa); + let xfe = (vc - ya) / ta; + let (xf, xl) = (xe - xfe, xa + xfe); + let (yg, yh, yi) = (h - ya, h - yb, h - yc); + b.poly(&[(xa, ya), (xb, yb), (hc, yc), (xd, yb), (xe, ya), (xf, vc), (xe, yg), (xd, yh), (hc, yi), (xb, yh), (xa, yg), (xl, vc)]); + Rect::new(xa, yb, xe, yh) + } + }; + Geometry { paths: vec![fill(b.take())], text_rect: text, handles: vec![], sites: mid_sites(w, h) } +} + +pub(super) 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), + } +} + +pub(super) 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) } +} + +pub(super) 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), + } +} diff --git a/crates/geom/src/preset/tests.rs b/crates/geom/src/preset/tests.rs new file mode 100644 index 000000000..dc18dcdb5 --- /dev/null +++ b/crates/geom/src/preset/tests.rs @@ -0,0 +1,420 @@ +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 partial_circle_angle_handles_map_back() { + // Start 0° sits at the right of the ellipse, end 270° at its top. + for name in ["pie", "chord", "arc"] { + let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap(); + assert_eq!(g.handles.iter().map(|h| h.index).collect::>(), vec![0, 1], "{name}"); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos); + assert_eq!(s.value_at(s.pos), 0.0, "{name}"); + assert_eq!(e.value_at(e.pos), 16200000.0, "{name}"); + // Dragging to the bottom of the ellipse gives 90°. + assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}"); + // Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100 + // ellipse at offset (√2000, √2000) from the centre. + let k = 2000f64.sqrt(); + let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap(); + let (s, e) = (&g.handles[0], &g.handles[1]); + assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos); + assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos); + for (deg, h) in [(45.0, s), (135.0, e)] { + let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2); + assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos); + assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos)); + } + } + for deg in [45.0, 135.0, 200.0, 315.0] { + let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + // Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse. + let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2); + assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos); + let d = start.pos - Point::new(100.0, 50.0); + assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos); + assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°"); + } + let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap(); + let start = g.handles.iter().find(|h| h.index == 0).unwrap(); + assert_eq!(start.value_at(start.pos), 10800000.0); + let thick = g.handles.iter().find(|h| h.index == 2).unwrap(); + assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0); +} + +#[test] +fn presets_with_adjust_values_expose_handles() { + // Presets whose adjust values have no handle of their own yet. + const NO_HANDLES: &[&str] = &[ + "curvedConnector3", + "snip2SameRect", + "snip2DiagRect", + "snipRoundRect", + "round1Rect", + "round2SameRect", + "round2DiagRect", + "teardrop", + "halfFrame", + "corner", + "diagStripe", + "foldedCorner", + "smileyFace", + "moon", + "gear6", + "gear9", + "bracketPair", + "bracePair", + "leftBracket", + "rightBracket", + "leftBrace", + "rightBrace", + "leftRightArrow", + "upDownArrow", + "quadArrow", + "leftRightUpArrow", + "bentArrow", + "uturnArrow", + "curvedRightArrow", + "curvedLeftArrow", + "curvedUpArrow", + "curvedDownArrow", + "leftUpArrow", + "bentUpArrow", + "circularArrow", + "leftCircularArrow", + "leftRightCircularArrow", + "swooshArrow", + "rightArrowCallout", + "downArrowCallout", + "leftArrowCallout", + "upArrowCallout", + "leftRightArrowCallout", + "upDownArrowCallout", + "quadArrowCallout", + "mathPlus", + "mathMinus", + "mathMultiply", + "mathDivide", + "mathEqual", + "mathNotEqual", + "ribbon2", + "ribbon", + "ellipseRibbon2", + "ellipseRibbon", + "leftRightRibbon", + "verticalScroll", + "horizontalScroll", + "wave", + "doubleWave", + "borderCallout1", + "borderCallout2", + "borderCallout3", + "accentCallout1", + "accentCallout2", + "accentCallout3", + "callout1", + "callout2", + "callout3", + "accentBorderCallout1", + "accentBorderCallout2", + "accentBorderCallout3", + ]; + for p in CATALOG { + if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) { + continue; + } + let g = build(p.name, 200.0, 100.0, &[]).unwrap(); + assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name); + } +} + +#[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()); +} + +fn on_path(g: &Geometry, p: Point, tol: f64) -> bool { + use kurbo::ParamCurveNearest; + g.paths.iter().any(|sp| sp.path.segments().any(|seg| seg.nearest(p, 1e-6).distance_sq <= tol * tol)) +} + +#[test] +fn fixed_shapes_follow_the_standards_coordinates() { + // Points of ECMA-376's definitions (in 21600ths), checked on a 216 × 108 box. + let (w, h) = (216.0, 108.0); + let at = |x: f64, y: f64| Point::new(w * x / 21600.0, h * y / 21600.0); + let g = build("lightningBolt", w, h, &[]).unwrap(); + for (x, y) in [(8472.0, 0.0), (16577.0, 12007.0), (21600.0, 21600.0), (0.0, 3890.0)] { + assert!(on_path(&g, at(x, y), 1e-6), "lightningBolt ({x}, {y})"); + } + let g = build("irregularSeal1", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14522.0, 0.0), 1e-6) && on_path(&g, at(8485.0, 21600.0), 1e-6)); + let g = build("irregularSeal2", w, h, &[]).unwrap(); + assert!(on_path(&g, at(14790.0, 0.0), 1e-6) && on_path(&g, at(4917.0, 21600.0), 1e-6)); + // The heart's point is the middle of the bottom edge, its notch a quarter down. + let g = build("heart", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 2.0, h), 1e-6) && on_path(&g, Point::new(w / 2.0, h / 4.0), 1e-6)); + let g = build("flowChartDocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(21600.0, 17322.0), 1e-6) && on_path(&g, at(0.0, 20172.0), 1e-6)); + // Punched tape: the top edge dips to a fifth of the height a quarter of the way along. + let g = build("flowChartPunchedTape", w, h, &[]).unwrap(); + assert!(on_path(&g, Point::new(w / 4.0, h / 5.0), 1e-3) && on_path(&g, Point::new(w * 0.75, 0.0), 1e-3)); + let g = build("flowChartMultidocument", w, h, &[]).unwrap(); + assert!(on_path(&g, at(2972.0, 0.0), 1e-6) && on_path(&g, at(18595.0, 18022.0), 1e-6)); +} + +#[test] +fn notched_arrow_notch_parallels_the_head() { + // presetShapeDefinitions: the notch's point is at x1 = dy1 · dx2 / hd2, so its edges run + // parallel to the head's (25 on a 200 × 100 arrow at the default adjusts). + for (w, h, adj, x1) in [(200.0, 100.0, [50000.0, 50000.0], 25.0), (300.0, 120.0, [80000.0, 30000.0], 28.8)] { + let g = build("notchedRightArrow", w, h, &adj).unwrap(); + let notch = g.paths[0].path.elements().iter().find_map(|e| match e { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) if (p.y - h / 2.0).abs() < 1e-9 && p.x < w / 2.0 => Some(*p), + _ => None, + }); + assert!(notch.is_some_and(|p| (p.x - x1).abs() < 1e-6), "{w}x{h} {adj:?}: notch at {notch:?}, want x = {x1}"); + } +} + +#[test] +fn equation_shapes_match_powerpoints_outlines() { + // Corners read off PowerPoint's own outlines of the presets at their default adjusts. + let g = build("mathMultiply", 216.0, 216.0, &[]).unwrap(); + for (x, y) in [(0.32333, 0.15702), (0.5, 0.33369), (0.84298, 0.32333), (0.66631, 0.5), (0.33369, 0.5)] { + assert!(on_path(&g, Point::new(216.0 * x, 216.0 * y), 0.01), "multiply ({x}, {y})"); + } + let g = build("mathEqual", 300.0, 120.0, &[]).unwrap(); + for (x, y) in [(0.13255, 0.5588), (0.86745, 0.794), (0.13255, 0.206), (0.86745, 0.4412)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.01), "equal ({x}, {y})"); + } + let g = build("mathPlus", 216.0, 216.0, &[]).unwrap(); + assert!(on_path(&g, Point::new(216.0 * 0.3824, 216.0 * 0.13255), 0.01) && on_path(&g, Point::new(216.0 * 0.86745, 216.0 * 0.6176), 0.01)); + // A tall "not equal": its slash, leaning at the default 110° from top to bottom, reaches + // past the box's sides exactly as far as PowerPoint's (-22.242 to 122.242). + let g = build("mathNotEqual", 100.0, 260.0, &[]).unwrap(); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0 + 22.242).abs() < 0.01 && (bb.x1 - 122.242).abs() < 0.01 && bb.y0.abs() < 1e-6 && (bb.y1 - 260.0).abs() < 1e-6, "{bb:?}"); +} + +#[test] +fn sun_and_waves_match_powerpoints_outlines() { + // Points read off PowerPoint's outlines of the presets (216 pt square, default adjusts). + let k = 216.0; + let g = build("sun", k, k, &[]).unwrap(); + for (x, y) in [(0.85352, 0.14644), (0.76161, 0.33987), (0.66013, 0.23839), (0.42825, 0.79823), (0.20177, 0.57175), (0.75, 0.5), (0.5, 0.25)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.02), "sun ({x}, {y})"); + } + let g = build("wave", k, k, &[]).unwrap(); + for (x, y) in [(0.21875, 0.00484), (1.0, 0.125), (1.0, 0.875), (0.0, 0.875)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.05), "wave ({x}, {y})"); + } + // Shifted: a wave of 300 × 120 with adj (20000, 8000) starts 0.16 of the width in. + let g = build("wave", 300.0, 120.0, &[20000.0, 8000.0]).unwrap(); + for (x, y) in [(0.18375, 0.00774), (0.84, 0.2), (1.0, 0.8), (0.16, 0.8)] { + assert!(on_path(&g, Point::new(300.0 * x, 120.0 * y), 0.05), "shifted wave ({x}, {y})"); + } +} + +#[test] +fn star_text_rectangles_follow_the_standard() { + // star5 at its default 19098 in a 144 pt square: the inner pentagon, about 0.38 of the + // width (PowerPoint wraps "Shape 3" at 18 pt there), from y ≈ 55 to ≈ 110. + let g = build("star5", 144.0, 144.0, &[]).unwrap(); + let t = g.text_rect; + assert!((t.width() - 55.0).abs() < 1.0, "{t:?}"); + assert!((t.y0 - 55.0).abs() < 1.0 && (t.y1 - 110.0).abs() < 1.0, "{t:?}"); + // star4 at 12500: the square through its inner points' 45° corners. + let g = build("star4", 144.0, 144.0, &[]).unwrap(); + assert!((g.text_rect.width() - 72.0 * 0.25 * 2f64.sqrt()).abs() < 0.5, "{:?}", g.text_rect); +} + +#[test] +fn stars_are_stretched_like_the_standards() { + // star5 in a 216 pt square: top point at the top middle, side points at 18 degrees on an + // ellipse 5.146% wider and 10.557% taller, centred 10.557% below the middle; its bottom + // points reach the box's bottom edge. + let k = 216.0; + let g = build("star5", k, k, &[]).unwrap(); + let (rx, ry, cy) = (k / 2.0 * 1.05146, k / 2.0 * 1.10557, k / 2.0 * 1.10557); + let a = 18f64.to_radians(); + assert!(on_path(&g, Point::new(k / 2.0, 0.0), 1e-6)); + assert!(on_path(&g, Point::new(k / 2.0 + rx * a.cos(), cy - ry * a.sin()), 1e-6)); + use kurbo::Shape as _; + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5 && (bb.y1 - k).abs() < 0.5, "{bb:?}"); + // star6 is 15.47% wider than its box's half-width allows (its points touch the sides). + let g = build("star6", k, k, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!((bb.x0).abs() < 0.5 && (bb.x1 - k).abs() < 0.5, "{bb:?}"); +} + +#[test] +fn moon_disk_and_drum_match_powerpoints_outlines() { + let k = 216.0; + // Moon, default: the inner edge runs through (0.5, 0.5) and (0.625, 0.2266) of the box. + let g = build("moon", k, k, &[]).unwrap(); + for (x, y) in [(0.5, 0.5), (0.626, 0.2266), (0.0, 0.5), (1.0, 0.0)] { + assert!(on_path(&g, Point::new(k * x, k * y), 0.5), "moon ({x}, {y})"); + } + // 300 × 120 at 30%: its inner edge reaches 30% of the width in. + let g = build("moon", 300.0, 120.0, &[30000.0]).unwrap(); + assert!(on_path(&g, Point::new(90.0, 60.0), 0.5)); + // Magnetic disk ends are ellipses h/6 high, drum ends w/6 across. + let g = build("flowChartMagneticDisk", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k, k / 6.0), 0.01) && on_path(&g, Point::new(k / 2.0, k / 3.0), 0.5)); + let g = build("flowChartMagneticDrum", k, k, &[]).unwrap(); + assert!(on_path(&g, Point::new(k / 6.0, 0.0), 0.01) && on_path(&g, Point::new(0.0, k / 2.0), 0.01)); +} + +#[test] +fn the_cloud_fills_its_box_at_any_aspect_and_has_creases() { + use kurbo::Shape as _; + for (w, h) in [(200.0, 200.0), (300.0, 100.0), (60.0, 180.0)] { + let g = build("cloud", w, h, &[]).unwrap(); + let bb = g.paths[0].path.bounding_box(); + assert!(bb.x0.abs() < w * 0.02 && (bb.x1 - w).abs() < w * 0.02, "{w}×{h}: {bb:?}"); + assert!(bb.y0.abs() < h * 0.02 && (bb.y1 - h).abs() < h * 0.02, "{w}×{h}: {bb:?}"); + assert_eq!(g.paths.len(), 2, "outline and creases"); + assert!(g.paths[1].stroke && g.paths[1].fill == FillMode::None); + } +} + +#[test] +fn spec_arcs_take_angles_as_directions_from_the_centre() { + // A DrawingML arc of a 2:1 ellipse from 45° to 90°: 45° is the direction (1, 1) from the + // centre, which on that ellipse is the point (r/√5)·(2, 1)… scaled so it lies on it. + let mut b = Pb::new(); + b.m(0.0, 0.0).arc_spec(200.0, 100.0, 45.0, 45.0); + let p = b.take(); + let end = p.elements().last().and_then(|e| match e { + kurbo::PathEl::CurveTo(_, _, q) => Some(*q), + _ => None, + }); + // Centre: the start sits at direction 45°, i.e. at (k, k) from it with (k/200)² + (k/100)² = 1. + let k = 1.0 / (1.0f64 / 40000.0 + 1.0 / 10000.0).sqrt(); + let c = Point::new(-k, -k); + let q = end.unwrap(); + assert!((q.x - c.x).abs() < 1e-6 && (q.y - (c.y + 100.0)).abs() < 1e-6, "{q:?} vs centre {c:?}"); +} + +#[test] +fn brackets_and_braces_fill_out_to_their_straight_side() { + // PowerPoint fills a bracket or brace (when it has a fill) between its curve and the + // straight side it opens to; only the curve is outlined. + for name in ["leftBracket", "rightBracket", "leftBrace", "rightBrace"] { + let g = build(name, 40.0, 100.0, &[]).unwrap(); + let filled: Vec<_> = g.paths.iter().filter(|p| p.fill != FillMode::None).collect(); + assert_eq!(filled.len(), 1, "{name}"); + assert!(!filled[0].stroke, "{name}: the filled area is not outlined"); + let area = filled[0].path.area().abs(); + assert!(area > 40.0 * 100.0 * 0.4 && area < 40.0 * 100.0, "{name}: {area}"); + assert!(g.paths.iter().any(|p| p.stroke && p.fill == FillMode::None), "{name}: the curve is outlined"); + } +} + +/// The end points of a path's move/line elements, in order. +fn vertices(p: &BezPath) -> Vec { + p.elements() + .iter() + .filter_map(|e| match e { + kurbo::PathEl::MoveTo(q) | kurbo::PathEl::LineTo(q) => Some(*q), + _ => None, + }) + .collect() +} + +#[test] +fn wedge_callout_mirrors_with_its_tip() { + // A tip below and right of the centre draws the mirror image of the same tip on the left; + // the same for a tip right of the centre, above or below it. presetShapeDefinitions puts + // the wedge's base at 2..5 twelfths of the edge on the near side of the centre and at + // 7..10 twelfths on the far side. + let (w, h) = (240.0, 120.0); + // Sorted and deduplicated: the outline closes on the corner it starts from, which the + // mirror moves to the opposite corner. + let sorted = |mut v: Vec| { + v.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + v.dedup_by(|a, b| (*a - *b).hypot() < 1e-9); + v + }; + for (right, left, flip_x) in [([10000.0, 80000.0], [-10000.0, 80000.0], true), ([80000.0, 10000.0], [80000.0, -10000.0], false)] { + let a = vertices(&build("wedgeRectCallout", w, h, &right).unwrap().paths[0].path); + let b = vertices(&build("wedgeRectCallout", w, h, &left).unwrap().paths[0].path); + let b: Vec = b.iter().map(|p| if flip_x { Point::new(w - p.x, p.y) } else { Point::new(p.x, h - p.y) }).collect(); + let (a, b) = (sorted(a), sorted(b)); + assert_eq!(a.len(), b.len(), "{right:?}: {a:?} vs mirrored {left:?}: {b:?}"); + for (p, q) in a.iter().zip(&b) { + assert!((*p - *q).hypot() < 1e-6, "{right:?}: {a:?}\n is not the mirror of {left:?}: {b:?}"); + } + } + // The bottom wedge of a tip right of the centre leaves the edge at 7/12 and 10/12 of the width. + let a = vertices(&build("wedgeRectCallout", w, h, &[10000.0, 80000.0]).unwrap().paths[0].path); + for x in [w * 7.0 / 12.0, w * 10.0 / 12.0] { + assert!(a.iter().any(|p| (p.x - x).abs() < 1e-6 && (p.y - h).abs() < 1e-6), "no wedge corner at ({x}, {h}): {a:?}"); + } +} diff --git a/crates/h264/src/deblock.rs b/crates/h264/src/deblock.rs index 0d815afff..3781cab90 100644 --- a/crates/h264/src/deblock.rs +++ b/crates/h264/src/deblock.rs @@ -302,71 +302,12 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { let top = usable(if my > 0 { Some(addr - mb_w) } else { None }); let alpha_off = sq.alpha_offset; let beta_off = sq.beta_offset; - let t8 = q.transform_8x8; - // bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y) - let mut bs = [[[0u8; 4]; 4]; 2]; - if q.kind.is_intra() { - for dir in 0..2 { - if (if dir == 0 { left } else { top }).is_some() { - bs[dir][0] = [4; 4]; - } - for e in 1..4 { - if !(t8 && e % 2 == 1) { - bs[dir][e] = [3; 4]; - } - } - } - } else { - let qids = ref_ids8(q, sq); - let q_uniform = (0..2).all(|l| { - let r = q.ref_idx[l]; - r[1] == r[0] && r[2] == r[0] && r[3] == r[0] && q.mv[l].iter().all(|m| *m == q.mv[l][0]) - }); - let qm: [Motion; 16] = std::array::from_fn(|r| block_motion(q, &qids, r)); - for dir in 0..2 { - let neighbor = if dir == 0 { left } else { top }; - if let Some(n) = neighbor { - let p = &pic.mbs[n]; - if p.kind.is_intra() { - bs[dir][0] = [4; 4]; - } else { - let pids = ref_ids8(p, &pic.slices[p.slice_num as usize]); - for k in 0..4 { - let (rq, rp) = if dir == 0 { (k * 4, k * 4 + 3) } else { (k, 12 + k) }; - bs[dir][0][k] = if (p.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { - 2 - } else { - motion_bs(&block_motion(p, &pids, rp), &qm[rq]) - }; - } - } - } - if q_uniform && q.nz_mask == 0 { - // one motion for the whole MB and no coefficients: all internal edges have bS 0 - continue; - } - for e in 1..4 { - if t8 && e % 2 == 1 { - continue; - } - for k in 0..4 { - let (rq, rp) = if dir == 0 { (k * 4 + e, k * 4 + e - 1) } else { (e * 4 + k, e * 4 + k - 4) }; - bs[dir][e][k] = if (q.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&qm[rp], &qm[rq]) }; - } - } - } - } + let bs = boundary_strengths(pic, q, sq, [left, top]); let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 }; let q_qp = qp_of(q); let q_qpc = q.qpc; let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))]; - let params = |qpp: i32, qpq: i32, b: [u8; 4]| { - let qpav = (qpp + qpq + 1) >> 1; - let index_a = (qpav + alpha_off).clamp(0, 51) as usize; - let index_b = (qpav + beta_off).clamp(0, 51) as usize; - let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; - EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } - }; + let offsets = (alpha_off, beta_off); let width = pic.planes.width; let cwidth = pic.planes.cwidth; // luma @@ -376,7 +317,7 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { continue; } let qpp = if e == 0 { p_qp[dir].map(|p| p.0).unwrap_or(q_qp) } else { q_qp }; - let ep = params(qpp, q_qp, bs[dir][e]); + let ep = edge_params(offsets, qpp, q_qp, bs[dir][e]); if ep.alpha == 0 || ep.beta == 0 { // filterSamplesFlag needs |p0 - q0| < alpha and |p1 - p0| < beta (8.7.2.2): no // sample of the edge changes (low QPs) @@ -398,7 +339,7 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { continue; } let qpp = if e == 0 { p_qp[dir].map(|p| p.1[c]).unwrap_or(q_qpc[c]) } else { q_qpc[c] } as i32; - let ep = params(qpp, q_qpc[c] as i32, bs[dir][e]); + let ep = edge_params(offsets, qpp, q_qpc[c] as i32, bs[dir][e]); if ep.alpha == 0 || ep.beta == 0 { continue; } @@ -413,6 +354,78 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) { } } +/// Filter parameters of one edge from the neighbouring QPs and the slice's alpha/beta offsets. +fn edge_params((alpha_off, beta_off): (i32, i32), qpp: i32, qpq: i32, b: [u8; 4]) -> EdgeParams { + let qpav = (qpp + qpq + 1) >> 1; + let index_a = (qpav + alpha_off).clamp(0, 51) as usize; + let index_b = (qpav + beta_off).clamp(0, 51) as usize; + let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 }; + EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 } +} + +/// bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y). `neighbors` is +/// `[left, top]`. +fn boundary_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, neighbors: [Option; 2]) -> [[[u8; 4]; 4]; 2] { + if q.kind.is_intra() { intra_strengths(q.transform_8x8, neighbors) } else { inter_strengths(pic, q, sq, neighbors) } +} + +fn intra_strengths(t8: bool, neighbors: [Option; 2]) -> [[[u8; 4]; 4]; 2] { + let mut bs = [[[0u8; 4]; 4]; 2]; + for dir in 0..2 { + if neighbors[dir].is_some() { + bs[dir][0] = [4; 4]; + } + for e in 1..4 { + if !(t8 && e % 2 == 1) { + bs[dir][e] = [3; 4]; + } + } + } + bs +} + +fn inter_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, neighbors: [Option; 2]) -> [[[u8; 4]; 4]; 2] { + let mut bs = [[[0u8; 4]; 4]; 2]; + let t8 = q.transform_8x8; + let qids = ref_ids8(q, sq); + let q_uniform = (0..2).all(|l| { + let r = q.ref_idx[l]; + r[1] == r[0] && r[2] == r[0] && r[3] == r[0] && q.mv[l].iter().all(|m| *m == q.mv[l][0]) + }); + let qm: [Motion; 16] = std::array::from_fn(|r| block_motion(q, &qids, r)); + for dir in 0..2 { + if let Some(n) = neighbors[dir] { + bs[dir][0] = edge_strengths_to_neighbor(&pic.mbs[n], &pic.slices[pic.mbs[n].slice_num as usize], q, &qm, dir); + } + if q_uniform && q.nz_mask == 0 { + // one motion for the whole MB and no coefficients: all internal edges have bS 0 + continue; + } + for e in 1..4 { + if t8 && e % 2 == 1 { + continue; + } + for k in 0..4 { + let (rq, rp) = if dir == 0 { (k * 4 + e, k * 4 + e - 1) } else { (e * 4 + k, e * 4 + k - 4) }; + bs[dir][e][k] = if (q.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&qm[rp], &qm[rq]) }; + } + } + } + bs +} + +/// bS of the macroblock edge shared with neighbour `p` (left for `dir` 0, top for `dir` 1). +fn edge_strengths_to_neighbor(p: &MbState, p_slice: &SliceInfo, q: &MbState, qm: &[Motion; 16], dir: usize) -> [u8; 4] { + if p.kind.is_intra() { + return [4; 4]; + } + let pids = ref_ids8(p, p_slice); + std::array::from_fn(|k| { + let (rq, rp) = if dir == 0 { (k * 4, k * 4 + 3) } else { (k, 12 + k) }; + if (p.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&block_motion(p, &pids, rp), &qm[rq]) } + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/h264/src/intra.rs b/crates/h264/src/intra.rs index decf29658..c2cd8fbaf 100644 --- a/crates/h264/src/intra.rs +++ b/crates/h264/src/intra.rs @@ -61,126 +61,102 @@ fn load_edge(plane: &[u8], stride: usize, x: usize, y: usize, n: usize, a: Avail /// Directional / DC prediction shared by Intra_4x4 and Intra_8x8 (8.3.1.2.x, 8.3.2.2.x). fn pred_nxn(mode: u8, e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) { - let mut put = |x: i32, y: i32, v: i32| out[y as usize * stride + x as usize] = v as u8; + let mut fill = |value: &dyn Fn(i32, i32) -> i32| { + for y in 0..n { + for x in 0..n { + out[y as usize * stride + x as usize] = value(x, y) as u8; + } + } + }; match mode { - 0 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.t(x)); - } - } - } - 1 => { - for y in 0..n { - for x in 0..n { - put(x, y, e.l(y)); - } - } - } + 0 => fill(&|x, _| e.t(x)), + 1 => fill(&|_, y| e.l(y)), 2 => { - let shift = if n == 4 { 2 } else { 3 }; - let st: i32 = (0..n).map(|x| e.t(x)).sum(); - let sl: i32 = (0..n).map(|y| e.l(y)).sum(); - let dc = match (a.top, a.left) { - (true, true) => (st + sl + n) >> (shift + 1), - (false, true) => (sl + (n >> 1)) >> shift, - (true, false) => (st + (n >> 1)) >> shift, - _ => 128, - }; - for y in 0..n { - for x in 0..n { - put(x, y, dc); - } - } - } - 3 => { - for y in 0..n { - for x in 0..n { - let v = if x == n - 1 && y == n - 1 { - (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 - } else { - (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 4 => { - for y in 0..n { - for x in 0..n { - let v = if x > y { - (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 - } else if x < y { - (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 - } else { - (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 5 => { - for y in 0..n { - for x in 0..n { - let z = 2 * x - y; - let v = if z >= 0 && z % 2 == 0 { - (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 - } else if z > 0 { - (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 6 => { - for y in 0..n { - for x in 0..n { - let z = 2 * y - x; - let v = if z >= 0 && z % 2 == 0 { - (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 - } else if z > 0 { - (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 - } else if z == -1 { - (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 - } else { - (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 - }; - put(x, y, v); - } - } - } - 7 => { - for y in 0..n { - for x in 0..n { - let i = x + (y >> 1); - let v = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }; - put(x, y, v); - } - } - } - _ => { - let zmax = 2 * n - 3; - for y in 0..n { - for x in 0..n { - let z = x + 2 * y; - let i = y + (x >> 1); - let v = if z < zmax && z % 2 == 0 { - (e.l(i) + e.l(i + 1) + 1) >> 1 - } else if z < zmax { - (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 - } else if z == zmax { - (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 - } else { - e.l(n - 1) - }; - put(x, y, v); - } - } + let dc = dc_value(e, n, a); + fill(&|_, _| dc); } + 3 => fill(&|x, y| diagonal_down_left(e, n, x, y)), + 4 => fill(&|x, y| diagonal_down_right(e, x, y)), + 5 => fill(&|x, y| vertical_right(e, x, y)), + 6 => fill(&|x, y| horizontal_down(e, x, y)), + 7 => fill(&|x, y| vertical_left(e, x, y)), + _ => fill(&|x, y| horizontal_up(e, n, x, y)), + } +} + +fn dc_value(e: &Edge, n: i32, a: Avail) -> i32 { + let shift = if n == 4 { 2 } else { 3 }; + let st: i32 = (0..n).map(|x| e.t(x)).sum(); + let sl: i32 = (0..n).map(|y| e.l(y)).sum(); + match (a.top, a.left) { + (true, true) => (st + sl + n) >> (shift + 1), + (false, true) => (sl + (n >> 1)) >> shift, + (true, false) => (st + (n >> 1)) >> shift, + _ => 128, + } +} + +fn diagonal_down_left(e: &Edge, n: i32, x: i32, y: i32) -> i32 { + if x == n - 1 && y == n - 1 { + (e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2 + } else { + (e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2 + } +} + +fn diagonal_down_right(e: &Edge, x: i32, y: i32) -> i32 { + if x > y { + (e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2 + } else if x < y { + (e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2 + } else { + (e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2 + } +} + +fn vertical_right(e: &Edge, x: i32, y: i32) -> i32 { + let z = 2 * x - y; + if z >= 0 && z % 2 == 0 { + (e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1 + } else if z > 0 { + (e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2 + } +} + +fn horizontal_down(e: &Edge, x: i32, y: i32) -> i32 { + let z = 2 * y - x; + if z >= 0 && z % 2 == 0 { + (e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1 + } else if z > 0 { + (e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2 + } else if z == -1 { + (e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2 + } else { + (e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2 + } +} + +fn vertical_left(e: &Edge, x: i32, y: i32) -> i32 { + let i = x + (y >> 1); + if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 } +} + +fn horizontal_up(e: &Edge, n: i32, x: i32, y: i32) -> i32 { + let zmax = 2 * n - 3; + let z = x + 2 * y; + let i = y + (x >> 1); + if z < zmax && z % 2 == 0 { + (e.l(i) + e.l(i + 1) + 1) >> 1 + } else if z < zmax { + (e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2 + } else if z == zmax { + (e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2 + } else { + e.l(n - 1) } } diff --git a/crates/h264/src/slice.rs b/crates/h264/src/slice.rs index e1cad290b..23ae971e9 100644 --- a/crates/h264/src/slice.rs +++ b/crates/h264/src/slice.rs @@ -142,6 +142,107 @@ pub struct SliceHeader { pub header_bits: usize, } +/// ref_pic_list_modification() for list 0 and list 1. +fn read_ref_pic_list_mods(r: &mut BitReader, slice_type: SliceType) -> Result<[Vec; 2]> { + let mut ref_pic_list_mod = [Vec::new(), Vec::new()]; + for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() { + let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; + if present && r.read_flag()? { + loop { + let idc = r.read_ue()?; + ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); + if idc == 3 { + break; + } + let value = r.read_ue()?; + mods.push(RefPicListMod { idc, value }); + ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); + } + } + } + Ok(ref_pic_list_mod) +} + +/// pred_weight_table(). +fn read_pred_weight_table(r: &mut BitReader, sps: &Sps, slice_type: SliceType, num_ref_idx_active: [u32; 2]) -> Result { + let luma_log2_denom = r.read_ue()?; + ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); + let has_chroma = sps.chroma_array_type() != 0; + let mut chroma_log2_denom = 0; + if has_chroma { + chroma_log2_denom = r.read_ue()?; + ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); + } + let mut lists = [Vec::new(), Vec::new()]; + let nlists = if slice_type.is_b() { 2 } else { 1 }; + for (l, entries) in lists.iter_mut().enumerate().take(nlists) { + for _ in 0..num_ref_idx_active[l] { + entries.push(read_weight_entry(r, has_chroma, luma_log2_denom, chroma_log2_denom)?); + } + } + let [l0, l1] = lists; + Ok(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 }) +} + +fn read_weight_entry(r: &mut BitReader, has_chroma: bool, luma_log2_denom: u32, chroma_log2_denom: u32) -> Result { + let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() }; + if r.read_flag()? { + e.luma_flag = true; + e.luma_weight = r.read_se()?; + e.luma_offset = r.read_se()?; + ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range"); + } + if has_chroma && r.read_flag()? { + e.chroma_flag = true; + for j in 0..2 { + e.chroma_weight[j] = r.read_se()?; + e.chroma_offset[j] = r.read_se()?; + ensure!((-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), "chroma weight out of range"); + } + } + Ok(e) +} + +/// The memory_management_control_operation list of dec_ref_pic_marking(). +fn read_mmcos(r: &mut BitReader) -> Result> { + let mut mmcos = Vec::new(); + loop { + let op = r.read_ue()?; + ensure!(op <= 6, "memory_management_control_operation out of range"); + if op == 0 { + return Ok(mmcos); + } + let mut m = Mmco { op, difference_of_pic_nums_minus1: 0, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }; + if op == 1 || op == 3 { + m.difference_of_pic_nums_minus1 = r.read_ue()?; + } + if op == 2 { + m.long_term_pic_num = r.read_ue()?; + } + if op == 3 || op == 6 { + m.long_term_frame_idx = r.read_ue()?; + } + if op == 4 { + m.max_long_term_frame_idx_plus1 = r.read_ue()?; + } + mmcos.push(m); + ensure!(mmcos.len() <= 66, "too many MMCOs"); + } +} + +/// Deblocking filter control: (disable_deblocking_filter_idc, alpha_c0_offset_div2, beta_offset_div2). +fn read_deblocking_params(r: &mut BitReader) -> Result<(u32, i32, i32)> { + let disable_deblocking_filter_idc = r.read_ue()?; + ensure!(disable_deblocking_filter_idc <= 2, "disable_deblocking_filter_idc out of range"); + if disable_deblocking_filter_idc == 1 { + return Ok((disable_deblocking_filter_idc, 0, 0)); + } + let alpha = r.read_se()?; + let beta = r.read_se()?; + ensure!((-6..=6).contains(&alpha) && (-6..=6).contains(&beta), "deblocking offsets out of range"); + Ok((disable_deblocking_filter_idc, alpha, beta)) +} + impl SliceHeader { pub fn qp(&self, pps: &Pps) -> i32 { pps.pic_init_qp.saturating_add(self.slice_qp_delta) @@ -214,59 +315,12 @@ impl SliceHeader { num_ref_idx_active[0] = 0; } ensure!(nal.nal_unit_type != 20 && nal.nal_unit_type != 21, "MVC slices are not supported"); - let mut ref_pic_list_mod = [Vec::new(), Vec::new()]; - for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() { - let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() }; - if present && r.read_flag()? { - loop { - let idc = r.read_ue()?; - ensure!(idc <= 5, "modification_of_pic_nums_idc out of range"); - if idc == 3 { - break; - } - let value = r.read_ue()?; - mods.push(RefPicListMod { idc, value }); - ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries"); - } - } - } - let mut pred_weight_table = None; - if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) { - let luma_log2_denom = r.read_ue()?; - ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range"); - let mut chroma_log2_denom = 0; - if sps.chroma_array_type() != 0 { - chroma_log2_denom = r.read_ue()?; - ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range"); - } - let mut lists = [Vec::new(), Vec::new()]; - let nlists = if slice_type.is_b() { 2 } else { 1 }; - for (l, entries) in lists.iter_mut().enumerate().take(nlists) { - for _ in 0..num_ref_idx_active[l] { - let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() }; - if r.read_flag()? { - e.luma_flag = true; - e.luma_weight = r.read_se()?; - e.luma_offset = r.read_se()?; - ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range"); - } - if sps.chroma_array_type() != 0 && r.read_flag()? { - e.chroma_flag = true; - for j in 0..2 { - e.chroma_weight[j] = r.read_se()?; - e.chroma_offset[j] = r.read_se()?; - ensure!( - (-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), - "chroma weight out of range" - ); - } - } - entries.push(e); - } - } - let [l0, l1] = lists; - pred_weight_table = Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 }); - } + let ref_pic_list_mod = read_ref_pic_list_mods(&mut r, slice_type)?; + let pred_weight_table = if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) { + Some(read_pred_weight_table(&mut r, sps, slice_type, num_ref_idx_active)?) + } else { + None + }; let mut no_output_of_prior_pics = false; let mut long_term_reference = false; let mut adaptive_ref_pic_marking = false; @@ -278,34 +332,7 @@ impl SliceHeader { } else { adaptive_ref_pic_marking = r.read_flag()?; if adaptive_ref_pic_marking { - loop { - let op = r.read_ue()?; - ensure!(op <= 6, "memory_management_control_operation out of range"); - if op == 0 { - break; - } - let mut m = Mmco { - op, - difference_of_pic_nums_minus1: 0, - long_term_pic_num: 0, - long_term_frame_idx: 0, - max_long_term_frame_idx_plus1: 0, - }; - if op == 1 || op == 3 { - m.difference_of_pic_nums_minus1 = r.read_ue()?; - } - if op == 2 { - m.long_term_pic_num = r.read_ue()?; - } - if op == 3 || op == 6 { - m.long_term_frame_idx = r.read_ue()?; - } - if op == 4 { - m.max_long_term_frame_idx_plus1 = r.read_ue()?; - } - mmcos.push(m); - ensure!(mmcos.len() <= 66, "too many MMCOs"); - } + mmcos = read_mmcos(&mut r)?; } } } @@ -325,21 +352,8 @@ impl SliceHeader { } slice_qs_delta = r.read_se()?; } - let mut disable_deblocking_filter_idc = 0; - let mut slice_alpha_c0_offset_div2 = 0; - let mut slice_beta_offset_div2 = 0; - if pps.deblocking_filter_control_present { - disable_deblocking_filter_idc = r.read_ue()?; - ensure!(disable_deblocking_filter_idc <= 2, "disable_deblocking_filter_idc out of range"); - if disable_deblocking_filter_idc != 1 { - slice_alpha_c0_offset_div2 = r.read_se()?; - slice_beta_offset_div2 = r.read_se()?; - ensure!( - (-6..=6).contains(&slice_alpha_c0_offset_div2) && (-6..=6).contains(&slice_beta_offset_div2), - "deblocking offsets out of range" - ); - } - } + let (disable_deblocking_filter_idc, slice_alpha_c0_offset_div2, slice_beta_offset_div2) = + if pps.deblocking_filter_control_present { read_deblocking_params(&mut r)? } else { (0, 0, 0) }; let mut slice_group_change_cycle = 0; if pps.num_slice_groups > 1 && (3..=5).contains(&pps.slice_group_map_type) { // Ceil(Log2(PicSizeInMapUnits / SliceGroupChangeRate + 1)); rate is not retained, so read diff --git a/crates/hevc/src/slice.rs b/crates/hevc/src/slice.rs index d6fc439a8..aadf6159f 100644 --- a/crates/hevc/src/slice.rs +++ b/crates/hevc/src/slice.rs @@ -154,6 +154,194 @@ impl SliceHeader { (st + lt) as u32 } + /// Fields present only in independent slice segments. + fn parse_independent_fields(&mut self, r: &mut BitReader, nal: NalHeader, pps: &Pps, sps: &Sps) -> Result<()> { + r.skip(pps.num_extra_slice_header_bits as usize)?; + let st = r.read_ue()?; + self.slice_type = match st { + 0 => SliceType::B, + 1 => SliceType::P, + 2 => SliceType::I, + _ => return invalid(format!("slice_type {st}")), + }; + if nal.is_irap() { + ensure!(self.slice_type == SliceType::I, "IRAP picture with inter slice"); + } + if pps.output_flag_present { + self.pic_output = r.read_flag()?; + } + if sps.separate_colour_plane { + r.skip(2)?; + } + if !nal.is_idr() { + self.parse_reference_picture_set(r, sps)?; + } + if sps.sao { + self.sao_luma = r.read_flag()?; + if sps.chroma_array_type() != 0 { + self.sao_chroma = r.read_flag()?; + } + } + if self.slice_type != SliceType::I { + self.parse_inter_params(r, pps, sps)?; + } + self.parse_qp_and_deblocking(r, pps, sps) + } + + /// POC LSB, short-term and long-term reference picture sets and the temporal MVP flag. + fn parse_reference_picture_set(&mut self, r: &mut BitReader, sps: &Sps) -> Result<()> { + self.poc_lsb = r.read_bits(sps.log2_max_poc_lsb)?; + if !r.read_flag()? { + let start = r.position(); + self.st_rps = StRps::parse(r, sps.st_rps.len(), &sps.st_rps, sps.st_rps.len())?; + self.st_rps_bits = r.position() - start; + } else { + ensure!(!sps.st_rps.is_empty(), "no short-term RPS in the SPS"); + let idx = if sps.st_rps.len() > 1 { r.read_bits(ceil_log2(sps.st_rps.len() as u32))? as usize } else { 0 }; + ensure!(idx < sps.st_rps.len(), "short_term_ref_pic_set_idx out of range"); + self.st_rps = sps.st_rps[idx].clone(); + } + if sps.long_term_refs_present { + self.parse_long_term_pics(r, sps)?; + } + if sps.temporal_mvp { + self.temporal_mvp = r.read_flag()?; + } + Ok(()) + } + + fn parse_long_term_pics(&mut self, r: &mut BitReader, sps: &Sps) -> Result<()> { + let num_lt_sps = if !sps.lt_ref_pics.is_empty() { r.read_ue()? } else { 0 }; + ensure!(num_lt_sps as usize <= sps.lt_ref_pics.len(), "num_long_term_sps out of range"); + let num_lt_pics = r.read_ue()?; + ensure!(num_lt_sps + num_lt_pics <= 32, "too many long-term pictures"); + let mut msb_acc = 0u32; + for i in 0..num_lt_sps + num_lt_pics { + let (lsb, used) = if i < num_lt_sps { + let idx = if sps.lt_ref_pics.len() > 1 { r.read_bits(ceil_log2(sps.lt_ref_pics.len() as u32))? } else { 0 }; + ensure!((idx as usize) < sps.lt_ref_pics.len(), "lt_idx_sps out of range"); + sps.lt_ref_pics[idx as usize] + } else { + (r.read_bits(sps.log2_max_poc_lsb)?, r.read_flag()?) + }; + let msb_present = r.read_flag()?; + let cycle = if msb_present { r.read_ue()? } else { 0 }; + // DeltaPocMsbCycleLt accumulates within each of the two groups (7-52) + if i == 0 || i == num_lt_sps { + msb_acc = cycle; + } else { + msb_acc = msb_acc.wrapping_add(cycle); + } + self.lt.push((lsb, used, msb_present, msb_acc)); + } + Ok(()) + } + + /// Reference index counts, list modification, collocated picture, prediction weights and merge candidates (P and B slices). + fn parse_inter_params(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { + self.num_ref_idx = [pps.num_ref_idx_l0_default, if self.is_b() { pps.num_ref_idx_l1_default } else { 0 }]; + if r.read_flag()? { + self.num_ref_idx[0] = r.read_ue_plus(1)?; + if self.is_b() { + self.num_ref_idx[1] = r.read_ue_plus(1)?; + } + } + ensure!(self.num_ref_idx[0] <= 15 && self.num_ref_idx[1] <= 15, "num_ref_idx_active out of range"); + let total = self.num_pic_total_curr(); + ensure!(total > 0, "inter slice without reference pictures in the RPS"); + if pps.lists_modification_present && total > 1 { + self.parse_list_entries(r, total)?; + } + if self.is_b() { + self.mvd_l1_zero = r.read_flag()?; + } + if pps.cabac_init_present { + self.cabac_init_flag = r.read_flag()?; + } + if self.temporal_mvp { + self.parse_collocated(r)?; + } + if (pps.weighted_pred && self.slice_type == SliceType::P) || (pps.weighted_bipred && self.is_b()) { + self.pwt = Some(parse_pwt(r, self, sps)?); + } + let five_minus = r.read_ue()?; + ensure!(five_minus <= 4, "five_minus_max_num_merge_cand out of range"); + self.max_num_merge_cand = 5 - five_minus; + Ok(()) + } + + fn parse_list_entries(&mut self, r: &mut BitReader, total: u32) -> Result<()> { + let bits = ceil_log2(total); + for l in 0..if self.is_b() { 2 } else { 1 } { + if r.read_flag()? { + let mut e = Vec::new(); + for _ in 0..self.num_ref_idx[l] { + let v = r.read_bits(bits)?; + ensure!(v < total, "list_entry out of range"); + e.push(v); + } + self.list_entry[l] = Some(e); + } + } + Ok(()) + } + + fn parse_collocated(&mut self, r: &mut BitReader) -> Result<()> { + if self.is_b() { + self.collocated_from_l0 = r.read_flag()?; + } + let l = if self.collocated_from_l0 { 0 } else { 1 }; + if self.num_ref_idx[l] > 1 { + self.collocated_ref_idx = r.read_ue()?; + ensure!(self.collocated_ref_idx < self.num_ref_idx[l], "collocated_ref_idx out of range"); + } + Ok(()) + } + + /// Slice QP delta, chroma QP offsets and the deblocking / loop filter overrides. + fn parse_qp_and_deblocking(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { + self.qp_delta = r.read_se()?; + let qp = pps.init_qp.checked_add(self.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?; + let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8); + ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range"); + if pps.slice_chroma_qp_offsets_present { + self.cb_qp_offset = r.read_se()?; + self.cr_qp_offset = r.read_se()?; + ensure!((-12..=12).contains(&self.cb_qp_offset) && (-12..=12).contains(&self.cr_qp_offset), "slice chroma QP offset out of range"); + } + let override_flag = if pps.deblocking_override_enabled { r.read_flag()? } else { false }; + if override_flag { + self.deblocking_disabled = r.read_flag()?; + if !self.deblocking_disabled { + self.beta_offset_div2 = r.read_se()?; + self.tc_offset_div2 = r.read_se()?; + ensure!((-6..=6).contains(&self.beta_offset_div2) && (-6..=6).contains(&self.tc_offset_div2), "deblocking offsets out of range"); + } + } + if pps.loop_filter_across_slices && (self.sao_luma || self.sao_chroma || !self.deblocking_disabled) { + self.loop_filter_across_slices = r.read_flag()?; + } + Ok(()) + } + + /// entry_point_offset list (tiles / wavefront substreams). + fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> { + self.entry_points.clear(); + if pps.tiles_enabled || pps.entropy_coding_sync { + let n = r.read_ue()?; + ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range"); + if n > 0 { + let len = r.read_ue_plus(1)?; + ensure!(len <= 32, "offset_len_minus1 out of range"); + for _ in 0..n { + let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?; + self.entry_points.push(off); + } + } + } + Ok(()) + } + /// Parse a slice segment header. `prev` is the header of the preceding independent slice segment /// of the same picture (needed for dependent slice segments). pub fn parse( @@ -225,154 +413,9 @@ impl SliceHeader { sh.dependent = dependent; sh.segment_address = segment_address; if !dependent { - r.skip(pps.num_extra_slice_header_bits as usize)?; - let st = r.read_ue()?; - sh.slice_type = match st { - 0 => SliceType::B, - 1 => SliceType::P, - 2 => SliceType::I, - _ => return invalid(format!("slice_type {st}")), - }; - if nal.is_irap() { - ensure!(sh.slice_type == SliceType::I, "IRAP picture with inter slice"); - } - if pps.output_flag_present { - sh.pic_output = r.read_flag()?; - } - if sps.separate_colour_plane { - r.skip(2)?; - } - if !nal.is_idr() { - sh.poc_lsb = r.read_bits(sps.log2_max_poc_lsb)?; - let from_sps = r.read_flag()?; - if !from_sps { - let start = r.position(); - sh.st_rps = StRps::parse(&mut r, sps.st_rps.len(), &sps.st_rps, sps.st_rps.len())?; - sh.st_rps_bits = r.position() - start; - } else { - ensure!(!sps.st_rps.is_empty(), "no short-term RPS in the SPS"); - let idx = if sps.st_rps.len() > 1 { r.read_bits(ceil_log2(sps.st_rps.len() as u32))? as usize } else { 0 }; - ensure!(idx < sps.st_rps.len(), "short_term_ref_pic_set_idx out of range"); - sh.st_rps = sps.st_rps[idx].clone(); - } - if sps.long_term_refs_present { - let num_lt_sps = if !sps.lt_ref_pics.is_empty() { r.read_ue()? } else { 0 }; - ensure!(num_lt_sps as usize <= sps.lt_ref_pics.len(), "num_long_term_sps out of range"); - let num_lt_pics = r.read_ue()?; - ensure!(num_lt_sps + num_lt_pics <= 32, "too many long-term pictures"); - let mut msb_acc = 0u32; - for i in 0..num_lt_sps + num_lt_pics { - let (lsb, used) = if i < num_lt_sps { - let idx = if sps.lt_ref_pics.len() > 1 { r.read_bits(ceil_log2(sps.lt_ref_pics.len() as u32))? } else { 0 }; - ensure!((idx as usize) < sps.lt_ref_pics.len(), "lt_idx_sps out of range"); - sps.lt_ref_pics[idx as usize] - } else { - (r.read_bits(sps.log2_max_poc_lsb)?, r.read_flag()?) - }; - let msb_present = r.read_flag()?; - let cycle = if msb_present { r.read_ue()? } else { 0 }; - // DeltaPocMsbCycleLt accumulates within each of the two groups (7-52) - if i == 0 || i == num_lt_sps { - msb_acc = cycle; - } else { - msb_acc = msb_acc.wrapping_add(cycle); - } - sh.lt.push((lsb, used, msb_present, msb_acc)); - } - } - if sps.temporal_mvp { - sh.temporal_mvp = r.read_flag()?; - } - } - if sps.sao { - sh.sao_luma = r.read_flag()?; - if sps.chroma_array_type() != 0 { - sh.sao_chroma = r.read_flag()?; - } - } - if sh.slice_type != SliceType::I { - sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }]; - if r.read_flag()? { - sh.num_ref_idx[0] = r.read_ue_plus(1)?; - if sh.is_b() { - sh.num_ref_idx[1] = r.read_ue_plus(1)?; - } - } - ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range"); - let total = sh.num_pic_total_curr(); - ensure!(total > 0, "inter slice without reference pictures in the RPS"); - if pps.lists_modification_present && total > 1 { - let bits = ceil_log2(total); - for l in 0..if sh.is_b() { 2 } else { 1 } { - if r.read_flag()? { - let mut e = Vec::new(); - for _ in 0..sh.num_ref_idx[l] { - let v = r.read_bits(bits)?; - ensure!(v < total, "list_entry out of range"); - e.push(v); - } - sh.list_entry[l] = Some(e); - } - } - } - if sh.is_b() { - sh.mvd_l1_zero = r.read_flag()?; - } - if pps.cabac_init_present { - sh.cabac_init_flag = r.read_flag()?; - } - if sh.temporal_mvp { - if sh.is_b() { - sh.collocated_from_l0 = r.read_flag()?; - } - let l = if sh.collocated_from_l0 { 0 } else { 1 }; - if sh.num_ref_idx[l] > 1 { - sh.collocated_ref_idx = r.read_ue()?; - ensure!(sh.collocated_ref_idx < sh.num_ref_idx[l], "collocated_ref_idx out of range"); - } - } - if (pps.weighted_pred && sh.slice_type == SliceType::P) || (pps.weighted_bipred && sh.is_b()) { - sh.pwt = Some(parse_pwt(&mut r, &sh, &sps)?); - } - let five_minus = r.read_ue()?; - ensure!(five_minus <= 4, "five_minus_max_num_merge_cand out of range"); - sh.max_num_merge_cand = 5 - five_minus; - } - sh.qp_delta = r.read_se()?; - let qp = pps.init_qp.checked_add(sh.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?; - let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8); - ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range"); - if pps.slice_chroma_qp_offsets_present { - sh.cb_qp_offset = r.read_se()?; - sh.cr_qp_offset = r.read_se()?; - ensure!((-12..=12).contains(&sh.cb_qp_offset) && (-12..=12).contains(&sh.cr_qp_offset), "slice chroma QP offset out of range"); - } - let override_flag = if pps.deblocking_override_enabled { r.read_flag()? } else { false }; - if override_flag { - sh.deblocking_disabled = r.read_flag()?; - if !sh.deblocking_disabled { - sh.beta_offset_div2 = r.read_se()?; - sh.tc_offset_div2 = r.read_se()?; - ensure!((-6..=6).contains(&sh.beta_offset_div2) && (-6..=6).contains(&sh.tc_offset_div2), "deblocking offsets out of range"); - } - } - if pps.loop_filter_across_slices && (sh.sao_luma || sh.sao_chroma || !sh.deblocking_disabled) { - sh.loop_filter_across_slices = r.read_flag()?; - } - } - sh.entry_points.clear(); - if pps.tiles_enabled || pps.entropy_coding_sync { - let n = r.read_ue()?; - ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range"); - if n > 0 { - let len = r.read_ue_plus(1)?; - ensure!(len <= 32, "offset_len_minus1 out of range"); - for _ in 0..n { - let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?; - sh.entry_points.push(off); - } - } + sh.parse_independent_fields(&mut r, nal, &pps, &sps)?; } + sh.parse_entry_points(&mut r, &pps, &sps)?; if pps.slice_header_extension_present { let len = r.read_ue()?; ensure!(len <= 256, "slice_segment_header_extension_length out of range"); diff --git a/crates/hevc/src/slicedec.rs b/crates/hevc/src/slicedec.rs index 6cc24cdcb..e870b686d 100644 --- a/crates/hevc/src/slicedec.rs +++ b/crates/hevc/src/slicedec.rs @@ -1105,7 +1105,52 @@ impl<'a> SliceDecoder<'a> { let n = 1usize << log2; let bypass = self.cu_transquant_bypass; let ts = if self.pps.transform_skip && !bypass && log2 == 2 { self.dd(TRANSFORM_SKIP + (c > 0) as usize) == 1 } else { false }; - // last significant coefficient + let scan_idx = self.scan_idx(x0, y0, log2, c); + let (mut lx, mut ly) = self.last_sig_coeff_pos(log2, c); + if scan_idx == 2 { + std::mem::swap(&mut lx, &mut ly); + } + ensure!((lx as usize) < n && (ly as usize) < n, "last significant coefficient outside the block"); + let t = TbCtx { c, log2, scan_idx, sdh: self.pps.sign_data_hiding && !bypass }; + let log2sb = log2 - 2; + let sb_scan = scan_order(log2sb, scan_idx); + let pos_scan = &SCAN_4[scan_idx]; + let sb_w = 1usize << log2sb; + let last_sb = sb_scan.iter().position(|&(x, y)| x as u32 == lx >> 2 && y as u32 == ly >> 2).unwrap_or(0); + let last_pos = pos_scan.iter().position(|&(x, y)| x as u32 == lx & 3 && y as u32 == ly & 3).unwrap_or(0); + self.coeffs[..n * n].fill(0); + let mut csbf = [0u8; 64]; + let mut prev_g1: Option = None; + let (mut max_x, mut max_y) = (0usize, 0usize); + for i in (0..=last_sb).rev() { + let sb = SubBlock { xs: sb_scan[i].0 as usize, ys: sb_scan[i].1 as usize }; + let right = if sb.xs + 1 < sb_w { csbf[sb.ys * 8 + sb.xs + 1] } else { 0 }; + let below = if sb.ys + 1 < sb_w { csbf[(sb.ys + 1) * 8 + sb.xs] } else { 0 }; + let mut infer_dc = false; + if i < last_sb && i > 0 { + let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 }; + csbf[sb.ys * 8 + sb.xs] = self.c.decode_decision(CODED_SUB_BLOCK + inc) as u8; + infer_dc = true; + } else { + csbf[sb.ys * 8 + sb.xs] = 1; + } + if csbf[sb.ys * 8 + sb.xs] == 0 { + continue; + } + let (sig, nsig) = self.sig_coeff_flags(&t, &sb, right + (below << 1), infer_dc, (i == last_sb).then_some(last_pos)); + if nsig == 0 { + continue; + } + let g = self.greater_flags(&t, i, nsig, &mut prev_g1); + let (mx, my) = self.store_levels(&t, &sb, &sig[..nsig], &g)?; + max_x = max_x.max(mx); + max_y = max_y.max(my); + } + self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y)) + } + + /// Decodes last_sig_coeff_{x,y}_prefix / suffix (before any scan-order swap). + fn last_sig_coeff_pos(&mut self, log2: u32, c: usize) -> (u32, u32) { let cmax = (log2 << 1) - 1; let (off, shift) = if c == 0 { (3 * (log2 - 2) + ((log2 - 1) >> 2), (log2 + 1) >> 2) } else { (15, log2 - 2) }; let mut px = 0; @@ -1116,209 +1161,144 @@ impl<'a> SliceDecoder<'a> { while py < cmax && self.dd(LAST_Y_PREFIX + (off + (py >> shift)) as usize) == 1 { py += 1; } - let mut lx = px; - if px > 3 { - let nb = (px >> 1) - 1; - lx = (1 << nb) * (2 + (px & 1)) + self.bypass_bits(nb); + let lx = self.last_pos_with_suffix(px); + let ly = self.last_pos_with_suffix(py); + (lx, ly) + } + + fn last_pos_with_suffix(&mut self, prefix: u32) -> u32 { + if prefix > 3 { + let nb = (prefix >> 1) - 1; + (1 << nb) * (2 + (prefix & 1)) + self.bypass_bits(nb) + } else { + prefix } - let mut ly = py; - if py > 3 { - let nb = (py >> 1) - 1; - ly = (1 << nb) * (2 + (py & 1)) + self.bypass_bits(nb); + } + + /// scanIdx (7.4.9.11): 0 diagonal, 1 horizontal, 2 vertical. + fn scan_idx(&self, x0: i32, y0: i32, log2: u32, c: usize) -> usize { + if !(self.cu_intra && (log2 == 2 || (log2 == 3 && c == 0))) { + return 0; } - // scanIdx (7.4.9.11) - let scan_idx = if self.cu_intra && (log2 == 2 || (log2 == 3 && c == 0)) { - let m = if c == 0 { self.pic.blk_at(x0, y0).ipm as u32 } else { self.intra_chroma_mode }; - if (6..=14).contains(&m) { - 2 - } else if (22..=30).contains(&m) { - 1 - } else { - 0 - } + let m = if c == 0 { self.pic.blk_at(x0, y0).ipm as u32 } else { self.intra_chroma_mode }; + if (6..=14).contains(&m) { + 2 + } else if (22..=30).contains(&m) { + 1 } else { 0 - }; - if scan_idx == 2 { - std::mem::swap(&mut lx, &mut ly); } - ensure!((lx as usize) < n && (ly as usize) < n, "last significant coefficient outside the block"); - let log2sb = log2 - 2; - let sb_scan = scan_order(log2sb, scan_idx); - let pos_scan = &SCAN_4[scan_idx]; - let sb_w = 1usize << log2sb; - let last_sb = sb_scan.iter().position(|&(x, y)| x as u32 == lx >> 2 && y as u32 == ly >> 2).unwrap_or(0); - let last_pos = pos_scan.iter().position(|&(x, y)| x as u32 == lx & 3 && y as u32 == ly & 3).unwrap_or(0); - self.coeffs[..n * n].fill(0); - let mut csbf = [0u8; 64]; - let mut prev_g1: Option = None; - let sdh = self.pps.sign_data_hiding && !bypass; - let (mut max_x, mut max_y) = (0usize, 0usize); - let c_off_sig = if c == 0 { 0 } else { 27 }; - for i in (0..=last_sb).rev() { - let (xs, ys) = (sb_scan[i].0 as usize, sb_scan[i].1 as usize); - let mut infer_dc = false; - let right = if xs + 1 < sb_w { csbf[ys * 8 + xs + 1] } else { 0 }; - let below = if ys + 1 < sb_w { csbf[(ys + 1) * 8 + xs] } else { 0 }; - if i < last_sb && i > 0 { - let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 }; - csbf[ys * 8 + xs] = self.c.decode_decision(CODED_SUB_BLOCK + inc) as u8; - infer_dc = true; - } else { - csbf[ys * 8 + xs] = 1; - } - // significance map - let mut sig = [0u8; 16]; // scan positions n of significant coefficients, descending - let mut nsig = 0; - let start: i32 = if i == last_sb { - sig[0] = last_pos as u8; + } + + /// Significance map of one coded sub-block: scan positions of the significant coefficients, in + /// descending order, and their count. + fn sig_coeff_flags(&mut self, t: &TbCtx, sb: &SubBlock, prev_csbf: u8, infer_dc: bool, last_pos: Option) -> ([u8; 16], usize) { + let pos_scan = &SCAN_4[t.scan_idx]; + let c_off_sig = if t.c == 0 { 0 } else { 27 }; + let mut infer_dc = infer_dc; + let mut sig = [0u8; 16]; + let mut nsig = 0; + let start: i32 = match last_pos { + Some(lp) => { + sig[0] = lp as u8; nsig = 1; - last_pos as i32 - 1 + lp as i32 - 1 + } + None => 15, + }; + for np in (0..=start).rev() { + let (xp, yp) = (pos_scan[np as usize].0 as usize, pos_scan[np as usize].1 as usize); + if np > 0 || !infer_dc { + let ctx = sig_coeff_ctx(t, sb, prev_csbf, (xp, yp)); + if self.c.decode_decision(SIG_COEFF + c_off_sig + ctx) == 1 { + sig[nsig] = np as u8; + nsig += 1; + infer_dc = false; + } } else { - 15 - }; - if csbf[ys * 8 + xs] != 0 { - let prev_csbf = right + (below << 1); - let mut np = start; - while np >= 0 { - let (xp, yp) = (pos_scan[np as usize].0 as usize, pos_scan[np as usize].1 as usize); - let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); - if np > 0 || !infer_dc { - let sig_ctx = if log2 == 2 { - CTX_IDX_MAP[(yc << 2) + xc] as usize - } else if xc + yc == 0 { - 0 - } else { - let mut s = match prev_csbf { - 0 => { - if xp + yp == 0 { - 2 - } else if xp + yp < 3 { - 1 - } else { - 0 - } - } - 1 => { - if yp == 0 { - 2 - } else if yp == 1 { - 1 - } else { - 0 - } - } - 2 => { - if xp == 0 { - 2 - } else if xp == 1 { - 1 - } else { - 0 - } - } - _ => 2, - }; - if c == 0 { - if xs + ys > 0 { - s += 3; - } - s += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; - } else { - s += if log2 == 3 { 9 } else { 12 }; - } - s - }; - if self.c.decode_decision(SIG_COEFF + c_off_sig + sig_ctx) == 1 { - sig[nsig] = np as u8; - nsig += 1; - infer_dc = false; - } - } else { - // np == 0 with inferSbDcSigCoeffFlag - sig[nsig] = 0; - nsig += 1; - } - np -= 1; - } - } - if nsig == 0 { - continue; - } - // greater1 / greater2 flags - let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; - if let Some(g) = prev_g1 - && g == 0 - { - ctx_set += 1; - } - let mut g1ctx = 1u32; - let mut gt1 = [0u8; 16]; - let mut first_g1: Option = None; - let c_off_g1 = if c > 0 { 16 } else { 0 }; - for k in 0..nsig.min(8) { - let inc = ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1; - let f = self.c.decode_decision(GT1 + inc); - gt1[k] = f as u8; - if f == 1 { - g1ctx = 0; - if first_g1.is_none() { - first_g1 = Some(k); - } - } else if g1ctx > 0 { - g1ctx += 1; - } - } - prev_g1 = Some(g1ctx); - let mut gt2 = 0u8; - if let Some(k) = first_g1 { - gt2 = self.c.decode_decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }) as u8; - let _ = k; - } - // signs - let last_sig_pos = sig[0] as i32; - let first_sig_pos = sig[nsig - 1] as i32; - let sign_hidden = sdh && last_sig_pos - first_sig_pos > 3; - let nsigns = if sign_hidden { nsig - 1 } else { nsig }; - let mut signs = if nsigns > 0 { self.bypass_bits(nsigns as u32) << (32 - nsigns) } else { 0 }; - // remaining levels - let mut rice = 0u32; - let mut sum_abs = 0i32; - for k in 0..nsig { - let mut base = 1 + gt1[k] as i32; - if Some(k) == first_g1 { - base += gt2 as i32; - } - let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; - let mut level = base; - if base == thresh { - let rem = self.coeff_abs_level_remaining(rice)?; - level = base + rem; - if level > 3 * (1 << rice) { - rice = (rice + 1).min(4); - } - } - let np = sig[k] as usize; - let (xc, yc) = (xs * 4 + pos_scan[np].0 as usize, ys * 4 + pos_scan[np].1 as usize); - let mut v = level; - if !(sign_hidden && k == nsig - 1) { - if signs & 0x8000_0000 != 0 { - v = -v; - } - signs <<= 1; - } - if sign_hidden { - sum_abs += level; - if k == nsig - 1 && sum_abs & 1 == 1 { - v = -v; - } - } - self.coeffs[yc * n + xc] = v; - max_x = max_x.max(xc); - max_y = max_y.max(yc); + // np == 0 with inferSbDcSigCoeffFlag + sig[nsig] = 0; + nsig += 1; } } - self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y)) + (sig, nsig) + } + + /// coeff_abs_level_greater1_flag / greater2_flag of one sub-block with `nsig` significant coefficients. + fn greater_flags(&mut self, t: &TbCtx, sb_index: usize, nsig: usize, prev_g1: &mut Option) -> GreaterFlags { + let mut ctx_set = if sb_index == 0 || t.c > 0 { 0 } else { 2 }; + if *prev_g1 == Some(0) { + ctx_set += 1; + } + let mut g1ctx = 1u32; + let mut g = GreaterFlags { gt1: [0u8; 16], first_g1: None, gt2: 0 }; + let c_off_g1 = if t.c > 0 { 16 } else { 0 }; + for k in 0..nsig.min(8) { + let inc = ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1; + let f = self.c.decode_decision(GT1 + inc); + g.gt1[k] = f as u8; + if f == 1 { + g1ctx = 0; + if g.first_g1.is_none() { + g.first_g1 = Some(k); + } + } else if g1ctx > 0 { + g1ctx += 1; + } + } + *prev_g1 = Some(g1ctx); + if g.first_g1.is_some() { + g.gt2 = self.c.decode_decision(GT2 + ctx_set + if t.c > 0 { 4 } else { 0 }) as u8; + } + g + } + + /// Reads signs and remaining levels of one sub-block and stores the coefficients. Returns the + /// largest x and y coefficient position written. + fn store_levels(&mut self, t: &TbCtx, sb: &SubBlock, sig: &[u8], g: &GreaterFlags) -> Result<(usize, usize)> { + let nsig = sig.len(); + let n = 1usize << t.log2; + let pos_scan = &SCAN_4[t.scan_idx]; + let sign_hidden = t.sdh && sig[0] as i32 - sig[nsig - 1] as i32 > 3; + let nsigns = if sign_hidden { nsig - 1 } else { nsig }; + let mut signs = if nsigns > 0 { self.bypass_bits(nsigns as u32) << (32 - nsigns) } else { 0 }; + let mut rice = 0u32; + let mut sum_abs = 0i32; + let (mut max_x, mut max_y) = (0usize, 0usize); + for k in 0..nsig { + let mut base = 1 + g.gt1[k] as i32; + if Some(k) == g.first_g1 { + base += g.gt2 as i32; + } + let thresh = if k < 8 { if Some(k) == g.first_g1 { 3 } else { 2 } } else { 1 }; + let mut level = base; + if base == thresh { + let rem = self.coeff_abs_level_remaining(rice)?; + level = base + rem; + if level > 3 * (1 << rice) { + rice = (rice + 1).min(4); + } + } + let np = sig[k] as usize; + let (xc, yc) = (sb.xs * 4 + pos_scan[np].0 as usize, sb.ys * 4 + pos_scan[np].1 as usize); + let mut v = level; + if !(sign_hidden && k == nsig - 1) { + if signs & 0x8000_0000 != 0 { + v = -v; + } + signs <<= 1; + } + if sign_hidden { + sum_abs += level; + if k == nsig - 1 && sum_abs & 1 == 1 { + v = -v; + } + } + self.coeffs[yc * n + xc] = v; + max_x = max_x.max(xc); + max_y = max_y.max(yc); + } + Ok((max_x, max_y)) } fn coeff_abs_level_remaining(&mut self, rice: u32) -> Result { @@ -1565,3 +1545,71 @@ impl<'a> SliceDecoder<'a> { } } } + +/// Per-transform-block parameters of residual coding. +struct TbCtx { + c: usize, + log2: u32, + scan_idx: usize, + sdh: bool, +} + +/// Position of a 4x4 sub-block in the sub-block grid. +struct SubBlock { + xs: usize, + ys: usize, +} + +/// Greater-than-1 / greater-than-2 flags of one sub-block. +struct GreaterFlags { + gt1: [u8; 16], + first_g1: Option, + gt2: u8, +} + +/// sigCtx (9.3.4.2.5) for the coefficient at (`xp`, `yp`) inside the sub-block. +fn sig_coeff_ctx(t: &TbCtx, sb: &SubBlock, prev_csbf: u8, (xp, yp): (usize, usize)) -> usize { + let (xc, yc) = (sb.xs * 4 + xp, sb.ys * 4 + yp); + if t.log2 == 2 { + CTX_IDX_MAP[(yc << 2) + xc] as usize + } else if xc + yc == 0 { + 0 + } else { + sig_ctx_neighbourhood(prev_csbf, xp, yp) + sig_ctx_offset(t, sb.xs + sb.ys > 0) + } +} + +/// Base sigCtx from the coded-sub-block flags of the right and below neighbours. +fn sig_ctx_neighbourhood(prev_csbf: u8, xp: usize, yp: usize) -> usize { + let near = |v: usize| match v { + 0 => 2, + 1 => 1, + _ => 0, + }; + match prev_csbf { + 0 => { + if xp + yp == 0 { + 2 + } else if xp + yp < 3 { + 1 + } else { + 0 + } + } + 1 => near(yp), + 2 => near(xp), + _ => 2, + } +} + +/// Offset added to sigCtx for the block size, colour component and sub-block position. +fn sig_ctx_offset(t: &TbCtx, not_first_sb: bool) -> usize { + if t.c == 0 { + let size_off = if t.log2 == 3 { if t.scan_idx == 0 { 9 } else { 15 } } else { 21 }; + size_off + if not_first_sb { 3 } else { 0 } + } else if t.log2 == 3 { + 9 + } else { + 12 + } +} diff --git a/crates/media/src/player.rs b/crates/media/src/player.rs index 3c972f2c7..6770b191c 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -515,311 +515,4 @@ impl Drop for Player { } #[cfg(test)] -mod tests { - use super::*; - - fn spec(duration: f64, looping: bool) -> VoiceSpec { - VoiceSpec { clip: ClipParams::default(), looping, duration, audio: false } - } - - #[test] - fn wall_clock_advances_pauses_and_seeks() { - let mut p = Player::new(None); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(2.0, false), None); - p.tick(10.0); - p.tick(10.2); - p.tick(10.4); - let s = p.status(1).map(|s| s.position).unwrap_or(-1.0); - assert!((s - 0.4).abs() < 1e-3, "{s}"); - p.pause(1); - p.tick(10.6); - assert!((p.status(1).map(|s| s.position).unwrap_or(-1.0) - 0.4).abs() < 1e-3); - assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Paused)); - p.seek(1, 1.5); - p.play(1, 1, &b, spec(2.0, false), None); - p.tick(10.8); - p.tick(11.0); - p.tick(11.2); - p.tick(11.4); - let st = p.status(1); - assert_eq!(st.as_ref().map(|s| s.state), Some(PlayState::Ended)); - assert_eq!(st.map(|s| s.position), Some(2.0)); - // Playing an ended voice starts over. - p.play(1, 1, &b, spec(2.0, false), None); - assert_eq!(p.status(1).map(|s| s.position), Some(0.0)); - } - - #[test] - fn looping_wraps_within_the_trimmed_range() { - let mut p = Player::new(None); - let b: Bytes = Arc::new(vec![]); - let mut s = spec(1.0, true); - s.clip.trim_start = 0.5; - p.play(7, 7, &b, s, None); - p.tick(0.0); - for i in 1..=4 { - p.tick(i as f64 * 0.2); - } - // 0.8 s played in a 0.5 s loop starting at 0.5: 0.5 + (0.8 mod 0.5) = 0.8. - let pos = p.status(7).map(|s| s.position).unwrap_or(-1.0); - assert!((pos - 0.8).abs() < 1e-3, "{pos}"); - assert_eq!(p.status(7).map(|s| s.state), Some(PlayState::Playing)); - } - - #[test] - fn mixer_applies_volume_fade_and_channel_mapping() { - let pcm = Arc::new(Pcm { rate: 10, channels: 1, samples: vec![1.0; 10] }); - let slot: Slot = Arc::new(OnceLock::new()); - let _ = slot.set(Ok(pcm)); - let clip = ClipParams { fade_in: 0.5, volume: 0.5, ..Default::default() }; - let mut m = Mixer { - voices: vec![Voice { - key: 1, - media_key: 1, - audio: Some(slot), - spec: VoiceSpec { clip, looping: false, duration: 1.0, audio: true }, - t: 0.0, - state: PlayState::Playing, - }], - }; - let mut buf = vec![0.0f32; 2 * 12]; - m.mix(Some(&mut buf), 12, 2, 10); - // Frame 0: fade starts at 0; frame 3: 0.3/0.5 × 0.5 = 0.3; both channels carry the mono source. - assert_eq!(buf[0], 0.0); - assert!((buf[6] - 0.3).abs() < 1e-5 && (buf[7] - 0.3).abs() < 1e-5, "{buf:?}"); - assert!((buf[16] - 0.5).abs() < 1e-5); - // Past the end: silence, and the voice has ended. - assert_eq!(buf[22], 0.0); - assert_eq!(m.voices[0].state, PlayState::Ended); - } - - #[test] - fn device_clock_drives_position() { - struct Fake(Arc>>>); - impl AudioOut for Fake { - fn start(&mut self, fill: Box) -> Result<(), String> { - *self.0.lock().map_err(|e| e.to_string())? = Some(fill); - Ok(()) - } - fn stop(&mut self) {} - } - let cb = Arc::new(Mutex::new(None)); - let mut p = Player::new(Some(Box::new(Fake(cb.clone())))); - let b: Bytes = Arc::new(vec![]); - p.play(3, 3, &b, spec(10.0, false), None); - // Wall time alone doesn't move a device-clocked voice… - p.tick(0.0); - p.tick(0.2); - assert_eq!(p.status(3).map(|s| s.position), Some(0.0)); - // …the device pulling 4800 frames at 48 kHz does. - let mut buf = vec![0.0; 4800 * 2]; - if let Some(f) = cb.lock().ok().and_then(|mut g| g.take()).as_mut() { - f(&mut buf, 2, 48_000); - } - let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); - assert!((pos - 0.1).abs() < 1e-6, "{pos}"); - } - - /// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. - fn wav_level(secs: f64, amp: f32) -> Bytes { - let rate = 8_000u32; - let n = (rate as f64 * secs).round() as usize; - let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); - let mut b = Vec::new(); - b.extend_from_slice(b"RIFF"); - b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); - b.extend_from_slice(b"WAVEfmt "); - b.extend_from_slice(&16u32.to_le_bytes()); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&1u16.to_le_bytes()); - b.extend_from_slice(&rate.to_le_bytes()); - b.extend_from_slice(&(rate * 2).to_le_bytes()); - b.extend_from_slice(&2u16.to_le_bytes()); - b.extend_from_slice(&16u16.to_le_bytes()); - b.extend_from_slice(b"data"); - b.extend_from_slice(&(data.len() as u32).to_le_bytes()); - b.extend_from_slice(&data); - Arc::new(b) - } - - fn audible(duration: f64) -> VoiceSpec { - VoiceSpec { clip: ClipParams::default(), looping: false, duration, audio: true } - } - - /// First mixed sample of voice `key`, after its audio has decoded. - fn first_sample(p: &Player, key: u64) -> f32 { - assert!(p.wait_loaded(key, std::time::Duration::from_secs(5)), "decode"); - let mut buf = vec![0.0f32; 4]; - p.render(&mut buf, 1, 8_000); - buf[0] - } - - #[test] - fn replacing_the_source_plays_the_new_audio_and_keeps_its_bytes() { - let mut p = Player::new(None); - let loud = wav_level(0.05, 0.5); - let quiet = wav_level(0.10, 0.25); - let loud_key = Arc::as_ptr(&loud) as u64; - let quiet_key = Arc::as_ptr(&quiet) as u64; - assert_ne!(loud_key, quiet_key); - - // Duration 0: the clock takes its length from the decoded audio, so a stale slot would - // report the previous clip. - p.play(1, loud_key, &loud, audible(0.0), None); - let got = first_sample(&p, 1); - assert!((got - 0.5).abs() < 0.02, "loud sample {got}"); - let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); - assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); - assert!(p.cache.get(&loud_key).is_some_and(|c| Arc::ptr_eq(&c.source, &loud))); - - // The caller's buffer can go away; the cache still owns the allocation its key names. - drop(loud); - assert_eq!(Arc::as_ptr(&p.cache.get(&loud_key).expect("cached").source) as u64, loud_key); - - p.play(1, quiet_key, &quiet, audible(0.0), None); - let got = first_sample(&p, 1); - assert!((got - 0.25).abs() < 0.02, "quiet sample {got}"); - let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); - assert!((dur - 0.10).abs() < 0.01, "quiet duration {dur}"); - assert!(p.cache.get(&quiet_key).is_some_and(|c| Arc::ptr_eq(&c.source, &quiet))); - // Resuming the same source does not jump back to the start. - let pos = p.status(1).map(|s| s.position).unwrap_or(-1.0); - p.play(1, quiet_key, &quiet, audible(0.0), None); - let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); - assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); - } - - type Fill = Box; - - /// An output that starts, then fails or goes quiet on command. - #[derive(Clone, Default)] - struct Flaky { - fill: Arc>>, - fail: Arc>>, - starts: Arc>, - } - impl AudioOut for Flaky { - fn start(&mut self, fill: Fill) -> Result<(), String> { - *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); - *self.starts.lock().map_err(|e| e.to_string())? += 1; - Ok(()) - } - fn stop(&mut self) { - if let Ok(mut f) = self.fill.lock() { - *f = None; - } - } - fn failure(&mut self) -> Option { - self.fail.lock().ok().and_then(|mut f| f.take()) - } - } - impl Flaky { - fn pull(&self, frames: usize) { - let mut buf = vec![0.0; frames * 2]; - if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { - f(&mut buf, 2, 48_000); - } - } - fn starts(&self) -> u32 { - self.starts.lock().map(|s| *s).unwrap_or(0) - } - } - - fn pos(p: &Player, key: u64) -> f64 { - p.status(key).map(|s| s.position).unwrap_or(-1.0) - } - - #[test] - fn a_failed_output_falls_back_to_the_wall_clock() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - p.tick(0.0); - out.pull(4800); - assert!((pos(&p, 1) - 0.1).abs() < 1e-6); - if let Ok(mut f) = out.fail.lock() { - *f = Some("unplugged".into()); - } - p.tick(0.2); - p.tick(0.4); - // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then - // the 0.4 s of wall time since the last tick before the failure. - assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); - assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); - // Playing again restarts the device. - p.play(2, 2, &b, spec(10.0, false), None); - assert_eq!(out.starts(), 2); - assert!(p.has_output()); - } - - #[test] - fn a_stalled_output_falls_back_to_the_wall_clock() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - // No callback ever arrives: after the first-callback grace the wall clock takes over. - for i in 0..=21 { - p.tick(i as f64 * 0.25); - } - let before = pos(&p, 1); - assert!(before > 0.0, "{before}"); - p.tick(5.5); - assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); - } - - #[test] - fn a_slow_device_that_answers_within_the_grace_is_not_a_stall() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - for i in 0..=8 { - p.tick(i as f64 * 0.25); - } - // Two seconds in, the device starts asking for samples: the same stream keeps playing. - out.pull(256); - p.tick(2.25); - p.tick(2.5); - assert_eq!(out.starts(), 1, "the stream was not restarted"); - } - - #[test] - fn an_idle_stream_without_callbacks_is_not_a_stall() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - p.play(1, 1, &b, spec(10.0, false), None); - p.pause(1); - for i in 0..=8 { - p.tick(i as f64 * 0.25); - } - // Resuming keeps the running stream: no restart. - p.play(1, 1, &b, spec(10.0, false), None); - assert_eq!(out.starts(), 1); - } - - #[test] - fn repeated_failures_give_up_on_the_device() { - let out = Flaky::default(); - let mut p = Player::new(Some(Box::new(out.clone()))); - let b: Bytes = Arc::new(vec![]); - let mut now = 0.0; - for _ in 0..MAX_OUTPUT_FAILURES { - p.play(1, 1, &b, spec(100.0, false), None); - if let Ok(mut f) = out.fail.lock() { - *f = Some("broken".into()); - } - p.tick(now); - now += 0.1; - } - assert!(!p.has_output()); - assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); - // Still plays, silently on wall time. - let before = pos(&p, 1); - p.tick(now + 0.2); - assert!(pos(&p, 1) > before); - } -} +mod tests; diff --git a/crates/media/src/player/tests.rs b/crates/media/src/player/tests.rs new file mode 100644 index 000000000..0e2f85b0b --- /dev/null +++ b/crates/media/src/player/tests.rs @@ -0,0 +1,306 @@ +use super::*; + +fn spec(duration: f64, looping: bool) -> VoiceSpec { + VoiceSpec { clip: ClipParams::default(), looping, duration, audio: false } +} + +#[test] +fn wall_clock_advances_pauses_and_seeks() { + let mut p = Player::new(None); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(2.0, false), None); + p.tick(10.0); + p.tick(10.2); + p.tick(10.4); + let s = p.status(1).map(|s| s.position).unwrap_or(-1.0); + assert!((s - 0.4).abs() < 1e-3, "{s}"); + p.pause(1); + p.tick(10.6); + assert!((p.status(1).map(|s| s.position).unwrap_or(-1.0) - 0.4).abs() < 1e-3); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Paused)); + p.seek(1, 1.5); + p.play(1, 1, &b, spec(2.0, false), None); + p.tick(10.8); + p.tick(11.0); + p.tick(11.2); + p.tick(11.4); + let st = p.status(1); + assert_eq!(st.as_ref().map(|s| s.state), Some(PlayState::Ended)); + assert_eq!(st.map(|s| s.position), Some(2.0)); + // Playing an ended voice starts over. + p.play(1, 1, &b, spec(2.0, false), None); + assert_eq!(p.status(1).map(|s| s.position), Some(0.0)); +} + +#[test] +fn looping_wraps_within_the_trimmed_range() { + let mut p = Player::new(None); + let b: Bytes = Arc::new(vec![]); + let mut s = spec(1.0, true); + s.clip.trim_start = 0.5; + p.play(7, 7, &b, s, None); + p.tick(0.0); + for i in 1..=4 { + p.tick(i as f64 * 0.2); + } + // 0.8 s played in a 0.5 s loop starting at 0.5: 0.5 + (0.8 mod 0.5) = 0.8. + let pos = p.status(7).map(|s| s.position).unwrap_or(-1.0); + assert!((pos - 0.8).abs() < 1e-3, "{pos}"); + assert_eq!(p.status(7).map(|s| s.state), Some(PlayState::Playing)); +} + +#[test] +fn mixer_applies_volume_fade_and_channel_mapping() { + let pcm = Arc::new(Pcm { rate: 10, channels: 1, samples: vec![1.0; 10] }); + let slot: Slot = Arc::new(OnceLock::new()); + let _ = slot.set(Ok(pcm)); + let clip = ClipParams { fade_in: 0.5, volume: 0.5, ..Default::default() }; + let mut m = Mixer { + voices: vec![Voice { + key: 1, + media_key: 1, + audio: Some(slot), + spec: VoiceSpec { clip, looping: false, duration: 1.0, audio: true }, + t: 0.0, + state: PlayState::Playing, + }], + }; + let mut buf = vec![0.0f32; 2 * 12]; + m.mix(Some(&mut buf), 12, 2, 10); + // Frame 0: fade starts at 0; frame 3: 0.3/0.5 × 0.5 = 0.3; both channels carry the mono source. + assert_eq!(buf[0], 0.0); + assert!((buf[6] - 0.3).abs() < 1e-5 && (buf[7] - 0.3).abs() < 1e-5, "{buf:?}"); + assert!((buf[16] - 0.5).abs() < 1e-5); + // Past the end: silence, and the voice has ended. + assert_eq!(buf[22], 0.0); + assert_eq!(m.voices[0].state, PlayState::Ended); +} + +#[test] +fn device_clock_drives_position() { + struct Fake(Arc>>>); + impl AudioOut for Fake { + fn start(&mut self, fill: Box) -> Result<(), String> { + *self.0.lock().map_err(|e| e.to_string())? = Some(fill); + Ok(()) + } + fn stop(&mut self) {} + } + let cb = Arc::new(Mutex::new(None)); + let mut p = Player::new(Some(Box::new(Fake(cb.clone())))); + let b: Bytes = Arc::new(vec![]); + p.play(3, 3, &b, spec(10.0, false), None); + // Wall time alone doesn't move a device-clocked voice… + p.tick(0.0); + p.tick(0.2); + assert_eq!(p.status(3).map(|s| s.position), Some(0.0)); + // …the device pulling 4800 frames at 48 kHz does. + let mut buf = vec![0.0; 4800 * 2]; + if let Some(f) = cb.lock().ok().and_then(|mut g| g.take()).as_mut() { + f(&mut buf, 2, 48_000); + } + let pos = p.status(3).map(|s| s.position).unwrap_or(-1.0); + assert!((pos - 0.1).abs() < 1e-6, "{pos}"); +} + +/// 16-bit mono WAV of a constant level, `secs` long at 8 kHz. +fn wav_level(secs: f64, amp: f32) -> Bytes { + let rate = 8_000u32; + let n = (rate as f64 * secs).round() as usize; + let data: Vec = (0..n).flat_map(|_| ((amp * 32767.0) as i16).to_le_bytes()).collect(); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + data.len() as u32).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + Arc::new(b) +} + +fn audible(duration: f64) -> VoiceSpec { + VoiceSpec { clip: ClipParams::default(), looping: false, duration, audio: true } +} + +/// First mixed sample of voice `key`, after its audio has decoded. +fn first_sample(p: &Player, key: u64) -> f32 { + assert!(p.wait_loaded(key, std::time::Duration::from_secs(5)), "decode"); + let mut buf = vec![0.0f32; 4]; + p.render(&mut buf, 1, 8_000); + buf[0] +} + +#[test] +fn replacing_the_source_plays_the_new_audio_and_keeps_its_bytes() { + let mut p = Player::new(None); + let loud = wav_level(0.05, 0.5); + let quiet = wav_level(0.10, 0.25); + let loud_key = Arc::as_ptr(&loud) as u64; + let quiet_key = Arc::as_ptr(&quiet) as u64; + assert_ne!(loud_key, quiet_key); + + // Duration 0: the clock takes its length from the decoded audio, so a stale slot would + // report the previous clip. + p.play(1, loud_key, &loud, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.5).abs() < 0.02, "loud sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.05).abs() < 0.01, "loud duration {dur}"); + assert!(p.cache.get(&loud_key).is_some_and(|c| Arc::ptr_eq(&c.source, &loud))); + + // The caller's buffer can go away; the cache still owns the allocation its key names. + drop(loud); + assert_eq!(Arc::as_ptr(&p.cache.get(&loud_key).expect("cached").source) as u64, loud_key); + + p.play(1, quiet_key, &quiet, audible(0.0), None); + let got = first_sample(&p, 1); + assert!((got - 0.25).abs() < 0.02, "quiet sample {got}"); + let dur = p.status(1).map(|s| s.duration).unwrap_or(-1.0); + assert!((dur - 0.10).abs() < 0.01, "quiet duration {dur}"); + assert!(p.cache.get(&quiet_key).is_some_and(|c| Arc::ptr_eq(&c.source, &quiet))); + // Resuming the same source does not jump back to the start. + let pos = p.status(1).map(|s| s.position).unwrap_or(-1.0); + p.play(1, quiet_key, &quiet, audible(0.0), None); + let again = p.status(1).map(|s| s.position).unwrap_or(-1.0); + assert!((again - pos).abs() < 1e-6, "resume moved {pos} -> {again}"); +} + +type Fill = Box; + +/// An output that starts, then fails or goes quiet on command. +#[derive(Clone, Default)] +struct Flaky { + fill: Arc>>, + fail: Arc>>, + starts: Arc>, +} +impl AudioOut for Flaky { + fn start(&mut self, fill: Fill) -> Result<(), String> { + *self.fill.lock().map_err(|e| e.to_string())? = Some(fill); + *self.starts.lock().map_err(|e| e.to_string())? += 1; + Ok(()) + } + fn stop(&mut self) { + if let Ok(mut f) = self.fill.lock() { + *f = None; + } + } + fn failure(&mut self) -> Option { + self.fail.lock().ok().and_then(|mut f| f.take()) + } +} +impl Flaky { + fn pull(&self, frames: usize) { + let mut buf = vec![0.0; frames * 2]; + if let Some(f) = self.fill.lock().ok().as_mut().and_then(|g| g.as_mut()) { + f(&mut buf, 2, 48_000); + } + } + fn starts(&self) -> u32 { + self.starts.lock().map(|s| *s).unwrap_or(0) + } +} + +fn pos(p: &Player, key: u64) -> f64 { + p.status(key).map(|s| s.position).unwrap_or(-1.0) +} + +#[test] +fn a_failed_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.tick(0.0); + out.pull(4800); + assert!((pos(&p, 1) - 0.1).abs() < 1e-6); + if let Ok(mut f) = out.fail.lock() { + *f = Some("unplugged".into()); + } + p.tick(0.2); + p.tick(0.4); + // The voice keeps moving on wall time instead of freezing: 0.1 s from the device, then + // the 0.4 s of wall time since the last tick before the failure. + assert!((pos(&p, 1) - 0.5).abs() < 1e-3, "{}", pos(&p, 1)); + assert_eq!(p.status(1).map(|s| s.state), Some(PlayState::Playing)); + // Playing again restarts the device. + p.play(2, 2, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 2); + assert!(p.has_output()); +} + +#[test] +fn a_stalled_output_falls_back_to_the_wall_clock() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + // No callback ever arrives: after the first-callback grace the wall clock takes over. + for i in 0..=21 { + p.tick(i as f64 * 0.25); + } + let before = pos(&p, 1); + assert!(before > 0.0, "{before}"); + p.tick(5.5); + assert!((pos(&p, 1) - before - 0.25).abs() < 1e-3); +} + +#[test] +fn a_slow_device_that_answers_within_the_grace_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Two seconds in, the device starts asking for samples: the same stream keeps playing. + out.pull(256); + p.tick(2.25); + p.tick(2.5); + assert_eq!(out.starts(), 1, "the stream was not restarted"); +} + +#[test] +fn an_idle_stream_without_callbacks_is_not_a_stall() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + p.play(1, 1, &b, spec(10.0, false), None); + p.pause(1); + for i in 0..=8 { + p.tick(i as f64 * 0.25); + } + // Resuming keeps the running stream: no restart. + p.play(1, 1, &b, spec(10.0, false), None); + assert_eq!(out.starts(), 1); +} + +#[test] +fn repeated_failures_give_up_on_the_device() { + let out = Flaky::default(); + let mut p = Player::new(Some(Box::new(out.clone()))); + let b: Bytes = Arc::new(vec![]); + let mut now = 0.0; + for _ in 0..MAX_OUTPUT_FAILURES { + p.play(1, 1, &b, spec(100.0, false), None); + if let Ok(mut f) = out.fail.lock() { + *f = Some("broken".into()); + } + p.tick(now); + now += 0.1; + } + assert!(!p.has_output()); + assert_eq!(out.starts(), MAX_OUTPUT_FAILURES); + // Still plays, silently on wall time. + let before = pos(&p, 1); + p.tick(now + 0.2); + assert!(pos(&p, 1) > before); +} diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index a4dcd4c25..afdb3c880 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -93,7 +93,7 @@ impl Fill { if w == 0.0 || h == 0.0 { return [0, 0, 0, 0]; } - if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) { + if *wrap == 4 && !((0.0..w).contains(&p.0) && (0.0..h).contains(&p.1)) { return [0, 0, 0, 0]; } let x = (p.0.rem_euclid(w) as usize).min(img.w - 1); @@ -286,10 +286,20 @@ impl Plus { if !self.active { return None; } + match typ { + 0x4008 => self.object(flags, r), + 0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r), + 0x4025..=0x4035 => self.record_state(cv, typ, flags, r), + 0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r), + _ => self.record_shape(cv, typ, flags, r), + } + } + + /// Rectangle, polygon, ellipse and path records. + fn record_shape(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { let compressed = flags & 0x4000 != 0; let id = usize::from(flags & 0xFF); match typ { - 0x4008 => self.object(flags, r), 0x400A => { let fill = self.brush_of(flags, r.u32(0)?)?; let p = self.painter(); @@ -348,6 +358,15 @@ impl Plus { self.painter().stroke(cv, &ops, pen); Some(()) } + _ => None, + } + } + + /// Curve, bezier and arc records. + fn record_curve(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { 0x4019 => { let pen = self.pen(id)?; let pts = points(r, 4, r.u32(0)? as usize, compressed)?; @@ -380,6 +399,29 @@ impl Plus { self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); Some(()) } + 0x4010 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let [start, sweep] = floats::<2>(r, 4)?; + let rect = *rects(r, 12, 1, compressed)?.first()?; + self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); + Some(()) + } + 0x4011 | 0x4012 => { + let pen = self.pen(id)?; + let [start, sweep] = floats::<2>(r, 0)?; + let rect = *rects(r, 8, 1, compressed)?.first()?; + self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); + Some(()) + } + _ => None, + } + } + + /// Clear, region fill, image and text records. + fn record_image_text_region(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { // Clear: the whole picture in one colour, whatever the clip (a transparent clear // leaves nothing to draw). 0x4009 => { @@ -419,6 +461,14 @@ impl Plus { } 0x401C => self.draw_string(cv, flags, r), 0x4036 => self.draw_driver_string(cv, flags, r), + _ => None, + } + } + + /// Save / restore, transform and clip records. + fn record_state(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + match typ { 0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)), 0x4027 => r.u32(24).map(|i| self.push(i)), 0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)), @@ -470,20 +520,6 @@ impl Plus { } Some(()) } - 0x4010 => { - let fill = self.brush_of(flags, r.u32(0)?)?; - let [start, sweep] = floats::<2>(r, 4)?; - let rect = *rects(r, 12, 1, compressed)?.first()?; - self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); - Some(()) - } - 0x4011 | 0x4012 => { - let pen = self.pen(id)?; - let [start, sweep] = floats::<2>(r, 0)?; - let rect = *rects(r, 8, 1, compressed)?.first()?; - self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); - Some(()) - } _ => None, } } diff --git a/crates/model/src/defaults.rs b/crates/model/src/defaults.rs index af6948eab..f8200e096 100644 --- a/crates/model/src/defaults.rs +++ b/crates/model/src/defaults.rs @@ -49,7 +49,7 @@ struct B { } impl B { - fn shape(&mut self, name: &str, kind: PhType, idx: u32, x: f64, y: f64, w: f64, h: f64) -> Shape { + fn shape(&mut self, name: &str, kind: PhType, idx: u32, [x, y, w, h]: [f64; 4]) -> Shape { self.next += 1; Shape { id: ShapeId(self.next), @@ -164,9 +164,9 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { 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 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( @@ -202,13 +202,13 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { }; 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); + 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: Some(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); + 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: Some(1.0), line_reduction: 0.0 }))); s }; @@ -251,7 +251,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // 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 t = b.shape("Title 1", PhType::CtrTitle, 0, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set( 0, @@ -270,7 +270,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { 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 st = b.shape("Subtitle 2", PhType::SubTitle, 1, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set( 0, @@ -309,7 +309,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // 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 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![] }); @@ -347,7 +347,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { 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 s = b.shape(name, PhType::Body, idx, [x, y, ww, hh]); let mut ls = ListStyle::default(); ls.set( 0, @@ -381,7 +381,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // 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 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![] }); @@ -405,12 +405,12 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // 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 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 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( @@ -444,7 +444,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) { // 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); + 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; diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs index b2645ac60..2b9cdf6b1 100644 --- a/crates/model/src/trendline.rs +++ b/crates/model/src/trendline.rs @@ -480,7 +480,7 @@ fn num(v: f64) -> String { if a == 0.0 { return "0".to_string(); } - if a >= 1.0e6 || a < 1.0e-3 { + if !(1.0e-3..1.0e6).contains(&a) { return format!("{v:.2e}").replace('+', ""); } let digits = if a >= 100.0 { diff --git a/crates/opus/src/celt/mod.rs b/crates/opus/src/celt.rs similarity index 88% rename from crates/opus/src/celt/mod.rs rename to crates/opus/src/celt.rs index 0cdb61cb0..fdd3f6940 100644 --- a/crates/opus/src/celt/mod.rs +++ b/crates/opus/src/celt.rs @@ -59,6 +59,80 @@ pub struct CeltDecoder { pcm_scratch: Vec, } +/// Detects the silence flag, advancing the decoder to the end of the frame when set. Returns the flag and the new bit position. +fn read_silence(dec: &mut RangeDecoder, total_bits_raw: i32) -> (bool, i32) { + let tell = dec.tell(); + let silence = tell >= total_bits_raw || (tell == 1 && dec.bit_logp(15)); + if silence { + // Pretend we've read all the remaining bits. + dec.skip_to_end(total_bits_raw); + return (true, total_bits_raw); + } + (false, tell) +} + +struct PostfilterParams { + gain: f32, + pitch: i32, + tapset: usize, + tell: i32, +} + +/// Reads the optional post-filter header (pitch, gain, tapset) at the start of a frame. +fn read_postfilter_params(dec: &mut RangeDecoder, start: usize, tell: i32, total_bits_raw: i32) -> PostfilterParams { + let mut p = PostfilterParams { gain: 0.0, pitch: 0, tapset: 0, tell }; + if start == 0 && tell + 16 <= total_bits_raw { + if dec.bit_logp(1) { + let octave = dec.uint(6); + p.pitch = ((16 << octave) + dec.bits(4 + octave)) as i32 - 1; + let qg = dec.bits(3); + if dec.tell() + 2 <= total_bits_raw { + p.tapset = dec.icdf(&TAPSET_ICDF, 2); + } + p.gain = 0.09375 * (qg + 1) as f32; + } + p.tell = dec.tell(); + } + p +} + +/// Decodes per-band dynamic allocation boosts. Returns the offsets, the fractional bit position and the remaining bit budget. +fn decode_dynalloc( + dec: &mut RangeDecoder, + start: usize, + end: usize, + c: usize, + lm: usize, + cap: &[i32], + total_bits_raw: i32, +) -> ([i32; NB_EBANDS], i32, i32) { + let mut offsets = [0i32; NB_EBANDS]; + let mut dynalloc_logp = 6; + let mut total_bits = total_bits_raw << BITRES; + let mut tellf = dec.tell_frac() as i32; + for i in start..end { + let width = (c as i32 * (EBANDS[i + 1] - EBANDS[i])) << lm; + let quanta = (width << BITRES).min((6 << BITRES).max(width)); + let mut loop_logp = dynalloc_logp; + let mut boost = 0; + while tellf + (loop_logp << BITRES) < total_bits && boost < cap[i] { + let flag = dec.bit_logp(loop_logp as u32); + tellf = dec.tell_frac() as i32; + if !flag { + break; + } + boost += quanta; + total_bits -= quanta; + loop_logp = 1; + } + offsets[i] = boost; + if boost > 0 { + dynalloc_logp = 2.max(dynalloc_logp - 1); + } + } + (offsets, tellf, total_bits) +} + impl CeltDecoder { /// `sample_rate` is the output rate (48000, 24000, 16000, 12000 or 8000). pub fn new(sample_rate: u32, channels: usize) -> CeltDecoder { @@ -176,34 +250,10 @@ impl CeltDecoder { } } let total_bits_raw = len as i32 * 8; - let mut tell = dec.tell(); - let silence = if tell >= total_bits_raw { - true - } else if tell == 1 { - dec.bit_logp(15) - } else { - false - }; - if silence { - // Pretend we've read all the remaining bits. - dec.skip_to_end(total_bits_raw); - tell = total_bits_raw; - } - let mut postfilter_gain = 0.0f32; - let mut postfilter_pitch = 0i32; - let mut postfilter_tapset = 0usize; - if start == 0 && tell + 16 <= total_bits_raw { - if dec.bit_logp(1) { - let octave = dec.uint(6); - postfilter_pitch = ((16 << octave) + dec.bits(4 + octave)) as i32 - 1; - let qg = dec.bits(3); - if dec.tell() + 2 <= total_bits_raw { - postfilter_tapset = dec.icdf(&TAPSET_ICDF, 2); - } - postfilter_gain = 0.09375 * (qg + 1) as f32; - } - tell = dec.tell(); - } + let (silence, mut tell) = read_silence(dec, total_bits_raw); + let pf = read_postfilter_params(dec, start, tell, total_bits_raw); + tell = pf.tell; + let (postfilter_gain, postfilter_pitch, postfilter_tapset) = (pf.gain, pf.pitch, pf.tapset); let is_transient = if lm > 0 && tell + 3 <= total_bits_raw { let t = dec.bit_logp(3); tell = dec.tell(); @@ -222,30 +272,7 @@ impl CeltDecoder { spread = dec.icdf(&SPREAD_ICDF, 5); } let cap = m.init_caps(lm, c); - let mut offsets = [0i32; NB_EBANDS]; - let mut dynalloc_logp = 6; - let mut total_bits = total_bits_raw << BITRES; - let mut tellf = dec.tell_frac() as i32; - for i in start..end { - let width = (c as i32 * (EBANDS[i + 1] - EBANDS[i])) << lm; - let quanta = (width << BITRES).min((6 << BITRES).max(width)); - let mut loop_logp = dynalloc_logp; - let mut boost = 0; - while tellf + (loop_logp << BITRES) < total_bits && boost < cap[i] { - let flag = dec.bit_logp(loop_logp as u32); - tellf = dec.tell_frac() as i32; - if !flag { - break; - } - boost += quanta; - total_bits -= quanta; - loop_logp = 1; - } - offsets[i] = boost; - if boost > 0 { - dynalloc_logp = 2.max(dynalloc_logp - 1); - } - } + let (offsets, tellf, total_bits) = decode_dynalloc(dec, start, end, c, lm, &cap, total_bits_raw); let alloc_trim = if tellf + (6 << BITRES) <= total_bits { dec.icdf(&TRIM_ICDF, 7) as i32 } else { 5 }; let mut bits = ((len as i32 * 8) << BITRES) - dec.tell_frac() as i32 - 1; let anti_collapse_rsv = if is_transient && lm >= 2 && bits >= ((lm as i32 + 2) << BITRES) { 1 << BITRES } else { 0 }; diff --git a/crates/opus/src/celt/bands.rs b/crates/opus/src/celt/bands.rs index f4b764d76..fa04b3c5f 100644 --- a/crates/opus/src/celt/bands.rs +++ b/crates/opus/src/celt/bands.rs @@ -269,8 +269,34 @@ struct Split { qalloc: i32, } +/// Block geometry for one theta decode: `n` samples, `bb` blocks per band, `b0` initial block count. +#[derive(Clone, Copy)] +struct ThetaShape { + n: i32, + bb: usize, + b0: usize, + lm: i32, + stereo: bool, +} + +/// Mid/side gains and delta for a decoded angle; also narrows `fill` to the blocks that stay coded. +fn split_gains(itheta: i32, n: i32, bb: usize, fill: &mut u32) -> (i32, i32, i32) { + if itheta == 0 { + *fill &= (1 << bb) - 1; + (32767, 0, -16384) + } else if itheta == 16384 { + *fill &= ((1 << bb) - 1) << bb; + (0, 32767, 16384) + } else { + let imid = bitexact_cos(itheta); + let iside = bitexact_cos(16384 - itheta); + (imid, iside, frac_mul16((n - 1) << 7, bitexact_log2tan(iside, imid))) + } +} + impl BandCtx<'_, '_, '_> { - fn compute_theta(&mut self, n: i32, b: &mut i32, bb: usize, b0: usize, lm: i32, stereo: bool, fill: &mut u32) -> Split { + fn compute_theta(&mut self, shape: ThetaShape, b: &mut i32, fill: &mut u32) -> Split { + let ThetaShape { n, bb, b0, lm, stereo } = shape; let i = self.i; let pulse_cap = self.m.log_n[i] + lm * (1 << BITRES); let offset = (pulse_cap >> 1) - if stereo && n == 2 { QTHETA_OFFSET_TWOPHASE } else { QTHETA_OFFSET }; @@ -282,67 +308,66 @@ impl BandCtx<'_, '_, '_> { let mut itheta = 0i32; let mut inv = false; if qn != 1 { - if stereo && n > 2 { - let p0 = 3u32; - let x0 = (qn / 2) as u32; - let ft = p0 * (x0 + 1) + x0; - let fs = self.dec.decode(ft); - let x = if fs < (x0 + 1) * p0 { fs / p0 } else { x0 + 1 + (fs - (x0 + 1) * p0) }; - let (fl, fh) = if x <= x0 { (p0 * x, p0 * (x + 1)) } else { ((x - 1 - x0) + (x0 + 1) * p0, (x - x0) + (x0 + 1) * p0) }; - self.dec.update(fl, fh, ft); - itheta = x as i32; - } else if b0 > 1 || stereo { - itheta = self.dec.uint(qn as u32 + 1) as i32; - } else { - let half = (qn >> 1) as u32; - let ft = (half + 1) * (half + 1); - let fm = self.dec.decode(ft); - let (fl, fs); - if fm < (half * (half + 1)) >> 1 { - let it = (isqrt32(8 * fm + 1) - 1) >> 1; - fs = it + 1; - fl = it * (it + 1) >> 1; - itheta = it as i32; - } else { - let q = qn as u32; - let it = (2 * (q + 1) - isqrt32(8 * (ft - fm - 1) + 1)) >> 1; - fs = q + 1 - it; - fl = ft - ((q + 1 - it) * (q + 2 - it) >> 1); - itheta = it as i32; - } - self.dec.update(fl, fl + fs, ft); - } - itheta = ((itheta as u32 * 16384) / qn as u32) as i32; + itheta = self.decode_itheta(qn, n, b0, stereo); } else if stereo { - if *b > 2 << BITRES && self.remaining_bits > 2 << BITRES { - inv = self.dec.bit_logp(2); - } - if self.disable_inv { - inv = false; - } - itheta = 0; + inv = self.decode_inversion(*b); } let qalloc = self.dec.tell_frac() as i32 - tell; *b -= qalloc; - let (imid, iside, delta); - if itheta == 0 { - imid = 32767; - iside = 0; - *fill &= (1 << bb) - 1; - delta = -16384; - } else if itheta == 16384 { - imid = 0; - iside = 32767; - *fill &= ((1 << bb) - 1) << bb; - delta = 16384; - } else { - imid = bitexact_cos(itheta); - iside = bitexact_cos(16384 - itheta); - delta = frac_mul16((n - 1) << 7, bitexact_log2tan(iside, imid)); - } + let (imid, iside, delta) = split_gains(itheta, n, bb, fill); Split { inv, imid, iside, delta, itheta, qalloc } } + /// Decodes the quantised stereo/band angle for `qn > 1` and scales it to the 0..=16384 range. + fn decode_itheta(&mut self, qn: i32, n: i32, b0: usize, stereo: bool) -> i32 { + let itheta = if stereo && n > 2 { + self.decode_itheta_stereo_triangle(qn) + } else if b0 > 1 || stereo { + self.dec.uint(qn as u32 + 1) as i32 + } else { + self.decode_itheta_triangle(qn) + }; + ((itheta as u32 * 16384) / qn as u32) as i32 + } + + fn decode_itheta_stereo_triangle(&mut self, qn: i32) -> i32 { + let p0 = 3u32; + let x0 = (qn / 2) as u32; + let ft = p0 * (x0 + 1) + x0; + let fs = self.dec.decode(ft); + let x = if fs < (x0 + 1) * p0 { fs / p0 } else { x0 + 1 + (fs - (x0 + 1) * p0) }; + let (fl, fh) = if x <= x0 { (p0 * x, p0 * (x + 1)) } else { ((x - 1 - x0) + (x0 + 1) * p0, (x - x0) + (x0 + 1) * p0) }; + self.dec.update(fl, fh, ft); + x as i32 + } + + fn decode_itheta_triangle(&mut self, qn: i32) -> i32 { + let half = (qn >> 1) as u32; + let ft = (half + 1) * (half + 1); + let fm = self.dec.decode(ft); + let (fl, fs, itheta); + if fm < (half * (half + 1)) >> 1 { + let it = (isqrt32(8 * fm + 1) - 1) >> 1; + fs = it + 1; + fl = it * (it + 1) >> 1; + itheta = it as i32; + } else { + let q = qn as u32; + let it = (2 * (q + 1) - isqrt32(8 * (ft - fm - 1) + 1)) >> 1; + fs = q + 1 - it; + fl = ft - ((q + 1 - it) * (q + 2 - it) >> 1); + itheta = it as i32; + } + self.dec.update(fl, fl + fs, ft); + itheta + } + + /// Reads the stereo inversion flag when there are enough bits left (never when inversion is disabled). + fn decode_inversion(&mut self, b: i32) -> bool { + let inv = b > 2 << BITRES && self.remaining_bits > 2 << BITRES && self.dec.bit_logp(2); + inv && !self.disable_inv + } + fn quant_band_n1(&mut self, x: &mut [f32], y: Option<&mut [f32]>, lowband_out: Option<&mut [f32]>) -> u32 { let dec_sign = |ctx: &mut Self| -> f32 { let mut sign = 0; @@ -386,7 +411,7 @@ impl BandCtx<'_, '_, '_> { fill = (fill & 1) | (fill << 1); } bb = (bb + 1) >> 1; - let sp = self.compute_theta(n as i32, &mut b, bb, b0, lm, false, &mut fill); + let sp = self.compute_theta(ThetaShape { n: n as i32, bb, b0, lm, stereo: false }, &mut b, &mut fill); let mid = sp.imid as f32 / 32768.0; let side = sp.iside as f32 / 32768.0; let mut delta = sp.delta; @@ -574,7 +599,7 @@ impl BandCtx<'_, '_, '_> { if n == 1 { return self.quant_band_n1(x, Some(y), lowband_out); } - let sp = self.compute_theta(n as i32, &mut b, bb, bb, lm, true, &mut fill); + let sp = self.compute_theta(ThetaShape { n: n as i32, bb, b0: bb, lm, stereo: true }, &mut b, &mut fill); let mid = sp.imid as f32 / 32768.0; let side = sp.iside as f32 / 32768.0; let mut cm; diff --git a/crates/opus/src/decoder.rs b/crates/opus/src/decoder.rs index a9cdee29d..cc3b01a0d 100644 --- a/crates/opus/src/decoder.rs +++ b/crates/opus/src/decoder.rs @@ -202,19 +202,10 @@ impl StreamDecoder { Some(m) => m, }; if audiosize > f20 { - let mut done = 0; - while done < audiosize { - let r = self.decode_frame(None, &mut pcm[done * ch..], (audiosize - done).min(f20), false)?; - done += r; - } + self.conceal_long(pcm, audiosize, f20)?; return Ok(frame_size); - } else if audiosize < f20 { - if audiosize > f10 { - audiosize = f10; - } else if mode != Mode::SilkOnly && audiosize > f5 && audiosize < f10 { - audiosize = f5; - } } + audiosize = shorten_concealment(audiosize, mode, fs); } } let data_len = data.map_or(0, <[_]>::len); @@ -248,76 +239,21 @@ impl StreamDecoder { // SILK. let mut pcm_silk: Vec = Vec::new(); if mode != Mode::CeltOnly { - pcm_silk.resize(frame_size.max(f10) * ch, 0.0); - if self.prev_mode == Some(Mode::CeltOnly) { - self.silk.reset(); - } - let payload_ms = (1000 * audiosize / fs).max(10); - let internal_rate = if mode == Mode::SilkOnly { - match bandwidth { - Some(Bandwidth::Narrowband) => 8000, - Some(Bandwidth::Mediumband) => 12000, - _ => 16000, - } - } else { - 16000 - }; - if data.is_some() { - self.silk.set_stream(self.stream_channels, internal_rate); - } - let lost = if data.is_none() { - LostFlag::Lost - } else if fec { - LostFlag::Lbrr - } else { - LostFlag::Normal - }; - let mut decoded = 0; - while decoded < frame_size { - let first = decoded == 0; - let n = match self.silk.decode(dec.as_deref_mut(), lost, first, payload_ms, &mut pcm_silk[decoded * ch..]) { - Ok(n) => n, - Err(e) => { - if lost != LostFlag::Normal { - let n = frame_size - decoded; - pcm_silk[decoded * ch..(decoded + n) * ch].fill(0.0); - n - } else { - return Err(e); - } - } - }; - if n == 0 { - break; - } - decoded += n; - } + pcm_silk = self.decode_silk_layer( + dec.as_deref_mut(), + SilkLayer { mode, bandwidth, lost: lost_flag(data.is_some(), fec), frame_size, audiosize, f10 }, + )?; } let mut start_band = 0; - let mut redundancy = false; - let mut celt_to_silk = false; - let mut redundancy_bytes = 0usize; - let mut len = data_len; + let mut redundancy = Redundancy::none(data_len); if !fec && mode != Mode::CeltOnly && let Some(d) = dec.as_deref_mut() - && d.tell() + 17 + 20 * (self.mode == Some(Mode::Hybrid)) as i32 <= 8 * len as i32 { - redundancy = if mode == Mode::Hybrid { d.bit_logp(12) } else { true }; - if redundancy { - celt_to_silk = d.bit_logp(1); - redundancy_bytes = if mode == Mode::Hybrid { d.uint(256) as usize + 2 } else { len - ((d.tell() as usize + 7) >> 3) }; - if redundancy_bytes > len || (len - redundancy_bytes) * 8 < d.tell() as usize { - len = 0; - redundancy_bytes = 0; - redundancy = false; - } else { - len -= redundancy_bytes; - } - d.shrink_storage(redundancy_bytes); - } + redundancy = Redundancy::parse(d, mode, self.mode == Some(Mode::Hybrid), data_len); } + let Redundancy { present: redundancy, celt_to_silk, bytes: redundancy_bytes, len } = redundancy; if mode != Mode::CeltOnly { start_band = 17; } @@ -375,15 +311,11 @@ impl StreamDecoder { smooth_fade(&tail, &redundant_audio[ch * f2_5..], &mut pcm[o..], f2_5, ch, window, self.rate); } if redundancy && celt_to_silk { - pcm[..f2_5 * ch].copy_from_slice(&redundant_audio[..f2_5 * ch]); - let cur: Vec = pcm[ch * f2_5..ch * 2 * f2_5].to_vec(); - smooth_fade(&redundant_audio[ch * f2_5..], &cur, &mut pcm[ch * f2_5..], f2_5, ch, window, self.rate); + self.fade_in_from(pcm, &redundant_audio, f2_5); } if transition { if audiosize >= f5 { - pcm[..f2_5 * ch].copy_from_slice(&pcm_transition[..f2_5 * ch]); - let cur: Vec = pcm[ch * f2_5..ch * 2 * f2_5].to_vec(); - smooth_fade(&pcm_transition[ch * f2_5..], &cur, &mut pcm[ch * f2_5..], f2_5, ch, window, self.rate); + self.fade_in_from(pcm, &pcm_transition, f2_5); } else { let cur: Vec = pcm[..ch * f2_5].to_vec(); smooth_fade(&pcm_transition, &cur, pcm, f2_5, ch, window, self.rate); @@ -403,6 +335,135 @@ impl StreamDecoder { self.prev_redundancy = redundancy && !celt_to_silk; Ok(audiosize) } + + /// Conceals a lost span longer than 20 ms by decoding it in 20 ms pieces. + fn conceal_long(&mut self, pcm: &mut [f32], audiosize: usize, f20: usize) -> Result<()> { + let mut done = 0; + while done < audiosize { + done += self.decode_frame(None, &mut pcm[done * self.channels..], (audiosize - done).min(f20), false)?; + } + Ok(()) + } + + /// Replaces the first `f2_5` samples of `pcm` with `src` and cross-fades the following `f2_5` samples from `src` into `pcm`. + fn fade_in_from(&self, pcm: &mut [f32], src: &[f32], f2_5: usize) { + let ch = self.channels; + let window = &crate::celt::mode().window; + pcm[..f2_5 * ch].copy_from_slice(&src[..f2_5 * ch]); + let cur: Vec = pcm[ch * f2_5..ch * 2 * f2_5].to_vec(); + smooth_fade(&src[ch * f2_5..], &cur, &mut pcm[ch * f2_5..], f2_5, ch, window, self.rate); + } + + /// Runs the SILK layer over the frame, zero-filling spans it cannot decode when the packet was lost. + fn decode_silk_layer(&mut self, mut dec: Option<&mut RangeDecoder>, l: SilkLayer) -> Result> { + let ch = self.channels; + let mut pcm_silk = vec![0.0; l.frame_size.max(l.f10) * ch]; + if self.prev_mode == Some(Mode::CeltOnly) { + self.silk.reset(); + } + let payload_ms = (1000 * l.audiosize / self.rate as usize).max(10); + if l.lost != LostFlag::Lost { + self.silk.set_stream(self.stream_channels, silk_internal_rate(l.mode, l.bandwidth)); + } + let mut decoded = 0; + while decoded < l.frame_size { + let first = decoded == 0; + let out = &mut pcm_silk[decoded * ch..]; + let n = match self.silk.decode(dec.as_deref_mut(), l.lost, first, payload_ms, out) { + Ok(n) => n, + Err(_) if l.lost != LostFlag::Normal => { + let n = l.frame_size - decoded; + pcm_silk[decoded * ch..(decoded + n) * ch].fill(0.0); + n + } + Err(e) => return Err(e), + }; + if n == 0 { + break; + } + decoded += n; + } + Ok(pcm_silk) + } +} + +/// Inputs for one SILK-layer decode. +struct SilkLayer { + mode: Mode, + bandwidth: Option, + lost: LostFlag, + frame_size: usize, + audiosize: usize, + f10: usize, +} + +fn lost_flag(has_data: bool, fec: bool) -> LostFlag { + if !has_data { + LostFlag::Lost + } else if fec { + LostFlag::Lbrr + } else { + LostFlag::Normal + } +} + +fn silk_internal_rate(mode: Mode, bandwidth: Option) -> u32 { + match (mode, bandwidth) { + (Mode::SilkOnly, Some(Bandwidth::Narrowband)) => 8000, + (Mode::SilkOnly, Some(Bandwidth::Mediumband)) => 12000, + _ => 16000, + } +} + +/// Picks the shorter concealment length for spans of at most 20 ms (10 ms, or 5 ms outside SILK-only mode). +fn shorten_concealment(audiosize: usize, mode: Mode, rate: usize) -> usize { + let f20 = rate / 50; + let f10 = f20 >> 1; + let f5 = f10 >> 1; + if audiosize >= f20 { + audiosize + } else if audiosize > f10 { + f10 + } else if mode != Mode::SilkOnly && audiosize > f5 && audiosize < f10 { + f5 + } else { + audiosize + } +} + +/// In-band redundancy header of a packet: whether a redundant CELT frame follows, its direction and size. +struct Redundancy { + present: bool, + celt_to_silk: bool, + bytes: usize, + /// Payload length left for the main frame. + len: usize, +} + +impl Redundancy { + fn none(len: usize) -> Self { + Self { present: false, celt_to_silk: false, bytes: 0, len } + } + + fn parse(d: &mut RangeDecoder, mode: Mode, prev_hybrid: bool, len: usize) -> Self { + let mut r = Self::none(len); + if d.tell() + 17 + 20 * prev_hybrid as i32 > 8 * len as i32 { + return r; + } + r.present = if mode == Mode::Hybrid { d.bit_logp(12) } else { true }; + if !r.present { + return r; + } + r.celt_to_silk = d.bit_logp(1); + r.bytes = if mode == Mode::Hybrid { d.uint(256) as usize + 2 } else { len - ((d.tell() as usize + 7) >> 3) }; + if r.bytes > len || (len - r.bytes) * 8 < d.tell() as usize { + r = Self { celt_to_silk: r.celt_to_silk, ..Self::none(0) }; + } else { + r.len -= r.bytes; + } + d.shrink_storage(r.bytes); + r + } } fn smooth_fade(in1: &[f32], in2: &[f32], out: &mut [f32], overlap: usize, ch: usize, window: &[f32; OVERLAP], rate: u32) { diff --git a/crates/opus/src/silk/mod.rs b/crates/opus/src/silk.rs similarity index 85% rename from crates/opus/src/silk/mod.rs rename to crates/opus/src/silk.rs index e6e096e6d..6f739c3a0 100644 --- a/crates/opus/src/silk/mod.rs +++ b/crates/opus/src/silk.rs @@ -716,19 +716,7 @@ impl SilkDecoder { } let stereo_to_mono = nci == 1 && self.channels_internal_prev == 2 && self.internal_rate == 1000 * self.ch[0].fs_khz as u32; if self.ch[0].n_frames_decoded == 0 { - for c in self.ch.iter_mut().take(nci) { - let (nfpp, nb) = match payload_ms { - 10 => (1, 2), - 20 => (1, 4), - 40 => (2, 4), - 60 => (3, 4), - _ => return Err(Error::InvalidPacket("bad SILK frame size")), - }; - c.n_frames_per_packet = nfpp; - c.nb_subfr = nb; - let fs_khz = ((self.internal_rate >> 10) + 1) as usize; - c.set_fs(fs_khz, self.api_rate); - } + self.configure_channels(nci, payload_ms)?; } if self.api_channels == 2 && nci == 2 && self.channels_internal_prev == 1 { self.stereo.pred_prev_q13 = [0; 2]; @@ -739,97 +727,20 @@ impl SilkDecoder { if lost != LostFlag::Lost && self.ch[0].n_frames_decoded == 0 { let d = dec.as_deref_mut().ok_or(Error::InvalidPacket("missing SILK data"))?; - for c in self.ch.iter_mut().take(nci) { - for i in 0..c.n_frames_per_packet { - c.vad_flags[i] = d.bit_logp(1); - } - c.lbrr_flag = d.bit_logp(1); - } - for c in self.ch.iter_mut().take(nci) { - c.lbrr_flags = [false; 3]; - if c.lbrr_flag { - if c.n_frames_per_packet == 1 { - c.lbrr_flags[0] = true; - } else { - let sym = d.icdf(LBRR_FLAGS[c.n_frames_per_packet - 2], 8) + 1; - for i in 0..c.n_frames_per_packet { - c.lbrr_flags[i] = (sym >> i) & 1 != 0; - } - } - } - } + self.read_packet_flags(d, nci); if lost == LostFlag::Normal { - // Skip LBRR data. - for i in 0..self.ch[0].n_frames_per_packet { - for n in 0..nci { - if self.ch[n].lbrr_flags[i] { - if nci == 2 && n == 0 { - stereo_decode_pred(d); - if !self.ch[1].lbrr_flags[i] { - d.icdf(&MID_ONLY, 8); - } - } - let cond = if i > 0 && self.ch[n].lbrr_flags[i - 1] { Cond::Conditionally } else { Cond::Independently }; - let c = &mut self.ch[n]; - c.decode_indices(d, i, true, cond); - let mut pulses = [0i32; MAX_FRAME + 16]; - decode_pulses(d, &mut pulses, c.indices.signal_type, c.indices.quant_offset_type, c.frame_length); - } - } - } + self.skip_lbrr_data(d, nci); } } - // Stereo prediction. if nci == 2 { - let fi = self.ch[0].n_frames_decoded; - let coded = lost == LostFlag::Normal || (lost == LostFlag::Lbrr && self.ch[0].lbrr_flags[fi]); - match dec.as_deref_mut() { - Some(d) if coded => { - self.ms_pred = stereo_decode_pred(d); - let side_inactive = - (lost == LostFlag::Normal && !self.ch[1].vad_flags[fi]) || (lost == LostFlag::Lbrr && !self.ch[1].lbrr_flags[fi]); - self.decode_only_middle = if side_inactive { d.icdf(&MID_ONLY, 8) == 1 } else { false }; - } - _ => { - self.ms_pred = self.stereo.pred_prev_q13; - } - } + self.decode_stereo_prediction(dec.as_deref_mut(), lost); } if nci == 2 && !self.decode_only_middle && self.prev_decode_only_middle { - let c = &mut self.ch[1]; - c.out_buf = [0; OUT_BUF]; - c.s_lpc_q14 = [0; MAX_LPC_ORDER]; - c.lag_prev = 100; - c.last_gain_index = 10; - c.prev_signal_type = 0; - c.first_frame_after_reset = true; + self.reset_side_channel(); } let l = self.ch[0].frame_length; let mut buf = [[0i16; MAX_FRAME + 2]; 2]; - let has_side = if lost == LostFlag::Normal { - !self.decode_only_middle - } else { - !self.prev_decode_only_middle || (nci == 2 && lost == LostFlag::Lbrr && self.ch[1].lbrr_flags[self.ch[1].n_frames_decoded]) - }; - for n in 0..nci { - if n == 0 || has_side { - let frame_index = self.ch[0].n_frames_decoded as isize - n as isize; - let cond = if frame_index <= 0 { - Cond::Independently - } else if lost == LostFlag::Lbrr { - if self.ch[n].lbrr_flags[frame_index as usize - 1] { Cond::Conditionally } else { Cond::Independently } - } else if n > 0 && self.prev_decode_only_middle { - Cond::IndependentlyNoLtpScaling - } else { - Cond::Conditionally - }; - let c = &mut self.ch[n]; - c.decode_frame(dec.as_deref_mut(), &mut buf[n][2..], lost, cond); - } else { - buf[n][2..2 + l].fill(0); - } - self.ch[n].n_frames_decoded += 1; - } + self.decode_channel_frames(dec, lost, nci, &mut buf); if self.api_channels == 2 && nci == 2 { let (a, b) = buf.split_at_mut(1); stereo_ms_to_lr(&mut self.stereo, &mut a[0], &mut b[0], self.ms_pred, self.ch[0].fs_khz, l); @@ -837,6 +748,142 @@ impl SilkDecoder { buf[0][..2].copy_from_slice(&self.stereo.s_mid); self.stereo.s_mid.copy_from_slice(&buf[0][l..l + 2]); } + let n_out = self.resample_to_output(&buf, nci, stereo_to_mono, out); + if lost == LostFlag::Lost { + for c in self.ch.iter_mut().take(nci) { + c.last_gain_index = 10; + } + } else { + self.prev_decode_only_middle = self.decode_only_middle; + } + Ok(n_out) + } + + /// Applies the packet's frame layout and internal sampling rate to the first `nci` channels. + fn configure_channels(&mut self, nci: usize, payload_ms: usize) -> Result<()> { + let (nfpp, nb) = match payload_ms { + 10 => (1, 2), + 20 => (1, 4), + 40 => (2, 4), + 60 => (3, 4), + _ => return Err(Error::InvalidPacket("bad SILK frame size")), + }; + let fs_khz = ((self.internal_rate >> 10) + 1) as usize; + for c in self.ch.iter_mut().take(nci) { + c.n_frames_per_packet = nfpp; + c.nb_subfr = nb; + c.set_fs(fs_khz, self.api_rate); + } + Ok(()) + } + + /// Reads the per-frame VAD flags and LBRR flags at the start of a packet. + fn read_packet_flags(&mut self, d: &mut RangeDecoder, nci: usize) { + for c in self.ch.iter_mut().take(nci) { + for i in 0..c.n_frames_per_packet { + c.vad_flags[i] = d.bit_logp(1); + } + c.lbrr_flag = d.bit_logp(1); + } + for c in self.ch.iter_mut().take(nci) { + c.lbrr_flags = [false; 3]; + if !c.lbrr_flag { + continue; + } + if c.n_frames_per_packet == 1 { + c.lbrr_flags[0] = true; + } else { + let sym = d.icdf(LBRR_FLAGS[c.n_frames_per_packet - 2], 8) + 1; + for i in 0..c.n_frames_per_packet { + c.lbrr_flags[i] = (sym >> i) & 1 != 0; + } + } + } + } + + /// Consumes (without using) the in-band LBRR frames of a normally decoded packet. + fn skip_lbrr_data(&mut self, d: &mut RangeDecoder, nci: usize) { + for i in 0..self.ch[0].n_frames_per_packet { + for n in 0..nci { + if !self.ch[n].lbrr_flags[i] { + continue; + } + if nci == 2 && n == 0 { + stereo_decode_pred(d); + if !self.ch[1].lbrr_flags[i] { + d.icdf(&MID_ONLY, 8); + } + } + let cond = if i > 0 && self.ch[n].lbrr_flags[i - 1] { Cond::Conditionally } else { Cond::Independently }; + let c = &mut self.ch[n]; + c.decode_indices(d, i, true, cond); + let mut pulses = [0i32; MAX_FRAME + 16]; + decode_pulses(d, &mut pulses, c.indices.signal_type, c.indices.quant_offset_type, c.frame_length); + } + } + } + + /// Reads the mid/side prediction weights, or reuses the previous ones when this frame carries none. + fn decode_stereo_prediction(&mut self, dec: Option<&mut RangeDecoder>, lost: LostFlag) { + let fi = self.ch[0].n_frames_decoded; + let coded = lost == LostFlag::Normal || (lost == LostFlag::Lbrr && self.ch[0].lbrr_flags[fi]); + match dec { + Some(d) if coded => { + self.ms_pred = stereo_decode_pred(d); + let side_inactive = (lost == LostFlag::Normal && !self.ch[1].vad_flags[fi]) || (lost == LostFlag::Lbrr && !self.ch[1].lbrr_flags[fi]); + self.decode_only_middle = side_inactive && d.icdf(&MID_ONLY, 8) == 1; + } + _ => self.ms_pred = self.stereo.pred_prev_q13, + } + } + + /// Resets the side channel's decoder state when it resumes after a mid-only stretch. + fn reset_side_channel(&mut self) { + let c = &mut self.ch[1]; + c.out_buf = [0; OUT_BUF]; + c.s_lpc_q14 = [0; MAX_LPC_ORDER]; + c.lag_prev = 100; + c.last_gain_index = 10; + c.prev_signal_type = 0; + c.first_frame_after_reset = true; + } + + /// Frame-coding condition for channel `n` of the frame currently being decoded. + fn frame_condition(&self, n: usize, lost: LostFlag) -> Cond { + let frame_index = self.ch[0].n_frames_decoded as isize - n as isize; + if frame_index <= 0 { + Cond::Independently + } else if lost == LostFlag::Lbrr { + if self.ch[n].lbrr_flags[frame_index as usize - 1] { Cond::Conditionally } else { Cond::Independently } + } else if n > 0 && self.prev_decode_only_middle { + Cond::IndependentlyNoLtpScaling + } else { + Cond::Conditionally + } + } + + /// Decodes the current frame of every active channel into `buf` (silence for an inactive side channel). + fn decode_channel_frames(&mut self, mut dec: Option<&mut RangeDecoder>, lost: LostFlag, nci: usize, buf: &mut [[i16; MAX_FRAME + 2]; 2]) { + let has_side = if lost == LostFlag::Normal { + !self.decode_only_middle + } else { + !self.prev_decode_only_middle || (nci == 2 && lost == LostFlag::Lbrr && self.ch[1].lbrr_flags[self.ch[1].n_frames_decoded]) + }; + let l = self.ch[0].frame_length; + for n in 0..nci { + if n == 0 || has_side { + let cond = self.frame_condition(n, lost); + self.ch[n].decode_frame(dec.as_deref_mut(), &mut buf[n][2..], lost, cond); + } else { + buf[n][2..2 + l].fill(0); + } + self.ch[n].n_frames_decoded += 1; + } + } + + /// Resamples the decoded channels to the API rate into interleaved `out`; returns samples per channel. + fn resample_to_output(&mut self, buf: &[[i16; MAX_FRAME + 2]; 2], nci: usize, stereo_to_mono: bool, out: &mut [f32]) -> usize { + let l = self.ch[0].frame_length; let n_out = l * self.api_rate as usize / (self.ch[0].fs_khz * 1000); let ach = self.api_channels; let mut tmp = Vec::with_capacity(n_out); @@ -860,13 +907,6 @@ impl SilkDecoder { } } } - if lost == LostFlag::Lost { - for c in self.ch.iter_mut().take(nci) { - c.last_gain_index = 10; - } - } else { - self.prev_decode_only_middle = self.decode_only_middle; - } - Ok(n_out) + n_out } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 1b80ad6aa..32da17da9 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -178,7 +178,7 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let pdf = writer.document_mut(); let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); - let mut ex = Exporter { pres, opts, fonts: HashMap::new() }; + let mut ex = Exporter { pres, opts, fonts: HashMap::new(), backdrops: Backdrops::default() }; // Page plan: per page, the slides on it with their boxes (page points). let pages = plan_pages(pres, opts, &slides, sw, sh); // Bookmarks and slide → page lookup for internal links. @@ -391,6 +391,7 @@ struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, fonts: HashMap, + backdrops: Backdrops, } fn tf(a: Affine) -> Transform { @@ -406,8 +407,19 @@ fn grey(v: u8, opacity: f32) -> Fill { } } -/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. +/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores. Runs of +/// pixels that are all fully transparent or fully opaque, most of a slide, are skipped in blocks. fn straight(rgba: &mut [u8]) { + let (blocks, rest) = rgba.as_chunks_mut::<64>(); + for block in blocks { + if block.as_chunks::<4>().0.iter().fold(false, |m, px| m | (px[3].wrapping_sub(1) < 254)) { + straight_px(block); + } + } + straight_px(rest); +} + +fn straight_px(rgba: &mut [u8]) { for px in rgba.as_chunks_mut::<4>().0 { let a = px[3] as u32; if a != 0 && a != 255 { @@ -418,17 +430,58 @@ fn straight(rgba: &mut [u8]) { } } -/// A backdrop raster as one PDF image, composited onto white (slides are normally opaque). Equal -/// backdrops become equal images, which the writer stores once. -fn backdrop(mut img: deckcraft_render::Image) -> Image { - for px in img.pixels.as_chunks_mut::<4>().0 { - let a = px[3] as u32; - for c in &mut px[..3] { - *c = (*c as u32 + (255 - a)).min(255) as u8; +/// A backdrop raster composited onto white (slides are normally opaque, and opaque blocks are skipped). +fn opaque(rgba: &mut [u8]) { + let (blocks, rest) = rgba.as_chunks_mut::<64>(); + for block in blocks { + if !block.as_chunks::<4>().0.iter().fold(true, |m, px| m & (px[3] == 255)) { + opaque_px(block); } + } + opaque_px(rest); +} + +fn opaque_px(rgba: &mut [u8]) { + for px in rgba.as_chunks_mut::<4>().0 { + let k = 255 - px[3]; + px[0] = px[0].saturating_add(k); + px[1] = px[1].saturating_add(k); + px[2] = px[2].saturating_add(k); px[3] = 255; } - Image::from_rgba8(img.pixels, img.width, img.height) +} + +/// Most bytes of opaque backdrop rasters kept to recognise a backdrop seen before. +const BACKDROP_CACHE_BYTES: usize = 32 << 20; + +/// Backdrops already handed to the writer, most recent first, with their pixels. Slides that +/// share a layout render the same backdrop; reusing its image skips hashing and compressing the +/// raster again, and the writer stores it once either way (#845). +#[derive(Default)] +struct Backdrops(Vec<(Vec, u32, u32, Image)>); + +impl Backdrops { + fn image(&mut self, mut img: deckcraft_render::Image) -> Image { + opaque(&mut img.pixels); + let (w, h) = (img.width, img.height); + if let Some(i) = self.0.iter().position(|(px, pw, ph, _)| (*pw, *ph) == (w, h) && *px == img.pixels) { + let hit = self.0.remove(i); + let image = hit.3.clone(); + self.0.insert(0, hit); + return image; + } + if img.pixels.len() > BACKDROP_CACHE_BYTES { + return Image::from_rgba8(img.pixels, w, h); + } + let image = Image::from_rgba8(img.pixels.clone(), w, h); + self.0.insert(0, (img.pixels, w, h, image.clone())); + let mut total = 0; + self.0.retain(|(px, ..)| { + total += px.len(); + total <= BACKDROP_CACHE_BYTES + }); + image + } } /// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters. @@ -470,7 +523,7 @@ impl Exporter<'_> { && let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); - s.draw_image(backdrop(back), size); + s.draw_image(self.backdrops.image(back), size); // Taken, not copied: a print-resolution slide is ~16 MB. let mut rgba = front.pixels; if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d135d9f64..dde7a9b76 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -8,7 +8,7 @@ use kurbo::Rect; use super::{ MAX_RASTER_PIXELS, PAPERS, PageLayout, PdfError, PdfOptions, Presentation, export, glyph_text_ranges, handout_boxes, master_text_pieces, - master_texts, notes_frames, notes_slide_box, paper_size, raster_scale, + master_texts, notes_frames, notes_slide_box, opaque, paper_size, raster_scale, straight, }; use deckcraft_model::text::TextBody; @@ -587,6 +587,51 @@ fn slides_share_one_backdrop_raster_and_keep_their_pixels() { let want_alpha: Vec = straight.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); assert_eq!(rgb, want_rgb, "content colors are straight, not premultiplied"); assert_eq!(alpha, Some(want_alpha), "content alpha keeps the shape's coverage"); + + // A backdrop that comes back after another one is still stored once, and every page draws its + // own slide's backdrop. + let mut p = deck(4); + for (i, slide) in p.slides.iter_mut().enumerate() { + let s = Arc::make_mut(slide); + s.shapes.clear(); + let c = if i % 2 == 0 { deckcraft_model::Rgba::rgb(20, 90, 160) } else { deckcraft_model::Rgba::rgb(230, 230, 210) }; + s.background = Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(c)) }); + } + let stored = images(&p); + assert_eq!(stored.iter().map(|(pages, ..)| pages.clone()).collect::>(), [vec![0, 2], vec![1, 3]]); + for (pages, _, rgb, alpha, _) in &stored { + let expect = deckcraft_render::render_slide(&p, pages[0], &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() }); + let want: Vec = expect.pixels.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect(); + assert_eq!(rgb, &want, "pages {pages:?} draw their own backdrop"); + assert!(alpha.is_none()); + } +} + +/// The block-skipping pixel conversions give exactly the per-pixel formulas, in blocks that skip +/// and blocks that don't, and in a tail shorter than a block. +#[test] +fn backdrop_and_content_pixels_convert_per_pixel() { + let mut px: Vec = Vec::new(); + for a in 0..=255u8 { + for c in 0..=255u8 { + px.extend([c, c / 2, 255 - c, a]); + } + } + px.extend([0, 0, 0, 0].repeat(32)); + px.extend([9, 8, 7, 255].repeat(32)); + px.extend([1, 2, 3, 128, 4, 5, 6, 0]); + let (mut o, mut s) = (px.clone(), px.clone()); + opaque(&mut o); + straight(&mut s); + for ((p, o), s) in px.chunks(4).zip(o.chunks(4)).zip(s.chunks(4)) { + let a = p[3] as u32; + for k in 0..3 { + assert_eq!(o[k] as u32, (p[k] as u32 + 255 - a).min(255), "{p:?}"); + let want = if a == 0 || a == 255 { p[k] as u32 } else { ((p[k] as u32 * 255 + a / 2) / a).min(255) }; + assert_eq!(s[k] as u32, want, "{p:?}"); + } + assert_eq!((o[3], s[3]), (255, p[3])); + } } #[test] diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read.rs similarity index 83% rename from crates/pptx/src/read/mod.rs rename to crates/pptx/src/read.rs index bb717d450..18e8eb92f 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read.rs @@ -413,7 +413,56 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } let pres_part = Part::new(&pkg, &pres_path, &pres_doc); - // Masters, layouts and slides to read. + let (master_paths, slide_entries) = master_and_slide_paths(pres_el, &pres_part); + let (docs, max_id) = load_part_docs(&pkg, &master_paths, &slide_entries); + + let mut imp = Imp { + pkg: &pkg, + media: vec![], + media_by_part: HashMap::new(), + next: max_id.max(1), + slide_by_part: HashMap::new(), + custom_show_names: HashMap::new(), + table_style_ids: Vec::new(), + vml_previews: HashMap::new(), + kept_sidecar_bytes: 0, + kept_charts: HashMap::new(), + }; + // Slide ids first so hyperlinks anywhere can point at slides. + let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides); + read_custom_show_names(&mut imp, pres_el); + + let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); + p.masters.clear(); + p.media.clear(); + p.default_text_style = Default::default(); + + read_presentation_settings(&mut imp, &mut p, &pres_part, pres_el); + let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); + let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part); + read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); + p.tags = tags(&imp, &pres_part, pres_el); + read_custom_shows(&mut p, &imp, &pres_part, pres_el); + read_guides_and_show(&mut p, &imp, &pres_part); + read_notes_master(&mut imp, &mut p, &pres_part); + read_handout_master(&mut imp, &mut p, &pres_part); + read_document_properties(&mut p, &pkg, &root_rels); + + p.kept_parts = kept_parts(&pkg, &pres_path); + + inherit_placeholder_geometry(&mut p); + default_text_anchor(&mut p); + p.header_footer = header_footer(&p); + p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); + p.media = std::mem::take(&mut imp.media); + p.next_id = imp.next; + p.fix_next_id(); + Ok((p, lost_slides)) +} + +/// Masters, layouts and slides to read, in file order and without duplicates: (master paths, +/// (file slide id, slide path)). +fn master_and_slide_paths(pres_el: &El, pres_part: &Part) -> (Vec, Vec<(u32, String)>) { let master_paths: Vec = pres_el .child("sldMasterIdLst") .map(|l| l.children_named("sldMasterId").filter_map(|m| pres_part.rels.target(m.attr("r:id").unwrap_or(""))).map(String::from).collect()) @@ -429,15 +478,19 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE .collect() }) .unwrap_or_default(); - - let mut docs: HashMap = HashMap::new(); - let mut max_id = 0u32; let mut seen_masters = HashSet::new(); master_paths.retain(|m| seen_masters.insert(opc::norm(m))); + let mut seen_slides = HashSet::new(); + slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p))); + (master_paths, slide_entries) +} - let mut all_parts: Vec = master_paths.clone(); +/// Loads every master, layout and slide part once. Returns them by normalized path and the +/// largest shape id found in them. +fn load_part_docs(pkg: &Package, master_paths: &[String], slide_entries: &[(u32, String)]) -> (HashMap, u32) { + let mut all_parts: Vec = master_paths.to_vec(); let mut seen_layouts = HashSet::new(); - for m in &master_paths { + for m in master_paths { for r in pkg.rels(m).all_of("slideLayout") { let key = opc::norm(&r.target); if seen_layouts.insert(key) { @@ -445,35 +498,32 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } } } - let mut seen_slides = HashSet::new(); - slide_entries.retain(|(_, p)| seen_slides.insert(opc::norm(p))); all_parts.extend(slide_entries.iter().map(|(_, p)| p.clone())); + let mut docs: HashMap = HashMap::new(); + let mut max_id = 0u32; for p in &all_parts { let key = opc::norm(p); if docs.contains_key(&key) { continue; } - if let Some(d) = load(&pkg, p) { + if let Some(d) = load(pkg, p) { max_cnvpr(&d.root, &mut max_id); docs.insert(key, d); } } + (docs, max_id) +} - let mut imp = Imp { - pkg: &pkg, - media: vec![], - media_by_part: HashMap::new(), - next: max_id.max(1), - slide_by_part: HashMap::new(), - custom_show_names: HashMap::new(), - table_style_ids: Vec::new(), - vml_previews: HashMap::new(), - kept_sidecar_bytes: 0, - kept_charts: HashMap::new(), - }; - // Slide ids first so hyperlinks anywhere can point at slides. +/// Allocates an id for every readable slide; slides that are missing or unreadable are recorded in +/// `lost_slides` and left out. +fn register_slides( + imp: &mut Imp, + docs: &HashMap, + slide_entries: &[(u32, String)], + lost_slides: &mut Vec, +) -> Vec<(u32, String, SlideId)> { let mut slide_ids: Vec<(u32, String, SlideId)> = vec![]; - for (file_id, path) in &slide_entries { + for (file_id, path) in slide_entries { if !docs.contains_key(&opc::norm(path)) { log::warn!("pptx: slide {path} is missing or unreadable; skipped"); lost_slides.push(path.clone()); @@ -483,6 +533,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE imp.slide_by_part.insert(opc::norm(path), id); slide_ids.push((*file_id, path.clone(), id)); } + slide_ids +} + +fn read_custom_show_names(imp: &mut Imp, pres_el: &El) { if let Some(l) = pres_el.child("custShowLst") { for cs in l.children_named("custShow") { if let (Some(id), Some(name)) = (cs.attr("id"), cs.attr("name")) { @@ -490,12 +544,10 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } } } +} - let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false); - p.masters.clear(); - p.media.clear(); - p.default_text_style = Default::default(); - +/// Slide and notes size, first slide number, default text style and table styles. +fn read_presentation_settings(imp: &mut Imp, p: &mut Presentation, pres_part: &Part, pres_el: &El) { if let Some(sz) = pres_el.child("sldSz") { let w = sz.i64("cx").map(emu_to_pt).unwrap_or(960.0); let h = sz.i64("cy").map(emu_to_pt).unwrap_or(540.0); @@ -508,24 +560,26 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE } p.first_slide_number = pres_el.u32("firstSlideNum").unwrap_or(1).min(9999); if let Some(dts) = pres_el.child("defaultTextStyle") { - p.default_text_style = dml::list_style(&mut imp, &pres_part, dts); + p.default_text_style = dml::list_style(imp, pres_part, dts); } if let Some(r) = pres_part.rels.first_of("tableStyles") - && let Some(d) = load(&pkg, &r.target) + && let Some(d) = load(imp.pkg, &r.target) { - let part = Part::new(&pkg, &r.target, &d); - p.table_styles = table_styles(&mut imp, &part, &d.root); + let part = Part::new(imp.pkg, &r.target, &d); + p.table_styles = table_styles(imp, &part, &d.root); imp.table_style_ids = p.table_styles.iter().map(|s| s.id.clone()).collect(); } +} - // Masters and layouts. +/// Reads the slide masters and their layouts; a file without a readable master gets a default one. +fn read_masters(imp: &mut Imp, p: &mut Presentation, master_paths: &[String], docs: &HashMap) -> HashMap { let mut layout_by_part: HashMap = HashMap::new(); - for mp in &master_paths { + for mp in master_paths { let Some(doc) = docs.get(&opc::norm(mp)) else { log::warn!("pptx: master {mp} missing; skipped"); continue; }; - let master = read_master(&mut imp, mp, doc, &docs, &mut layout_by_part); + let master = read_master(imp, mp, doc, docs, &mut layout_by_part); p.masters.push(Arc::new(master)); } if p.masters.is_empty() { @@ -534,87 +588,111 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE imp.next = imp.next.saturating_add(200); p.masters.push(Arc::new(m)); } - let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); + layout_by_part +} - // Slides. +/// Reads every registered slide with its notes and comments. Returns the slide ids by file id. +fn read_slides( + imp: &mut Imp, + p: &mut Presentation, + pres_part: &Part, + docs: &HashMap, + slide_ids: &[(u32, String, SlideId)], + layout_by_part: &HashMap, +) -> HashMap { + let pkg = imp.pkg; + let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let mut file_slide_ids: HashMap = HashMap::new(); let mut notes_by_target: HashMap = HashMap::new(); - for (file_id, path, id) in &slide_ids { + for (file_id, path, id) in slide_ids { let Some(doc) = docs.get(&opc::norm(path)) else { continue }; - let part = Part::new(&pkg, path, doc); + let part = Part::new(pkg, path, doc); let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout); - let mut slide = read_slide(&mut imp, &part, doc, *id, layout); - // Notes. - if let Some(n) = part.rels.first_of("notesSlide") { - let n_key = opc::norm(&n.target); - if let Some(cached_notes) = notes_by_target.get(&n_key) { - slide.notes = cached_notes.clone(); - } else if let Some(nd) = load(&pkg, &n.target) { - let np = Part::new(&pkg, &n.target, &nd); - let notes = read_notes(&mut imp, &np, &nd.root); - notes_by_target.insert(n_key, notes.clone()); - slide.notes = notes; - } - } - // Comments. A modern part and the legacy part can describe the same threads (PowerPoint - // writes both): the modern one wins, its flattened legacy copy is skipped. - let mut parts: Vec<(Doc, bool)> = vec![]; - for c in part.rels.all_of("comments") { - if let Some(cd) = pkg.xml(&c.target) { - // The content type names the modern part; a package that types it only through a - // Default entry instead of an Override is still modern when its root is p188. - let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; - parts.push((cd, modern)); - } - } - let has_modern = parts.iter().any(|(_, m)| *m); - for (cd, modern) in parts { - if has_modern && !modern { - continue; - } - slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root, modern)); + let mut slide = read_slide(imp, &part, doc, *id, layout); + if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) { + slide.notes = notes; } + read_slide_comments(pkg, pres_part, &part, &mut slide); file_slide_ids.insert(*file_id, *id); p.slides.push(Arc::new(slide)); } + file_slide_ids +} - // Sections. - if let Some(ext) = pres_el.child("extLst") { - if let Some(sl) = ext.find("sectionLst") { - for s in sl.children_named("section").take(10_000) { - let slides = s - .child("sldIdLst") - .map(|l| l.children_named("sldId").filter_map(|x| x.u32("id").and_then(|i| file_slide_ids.get(&i).copied())).collect()) - .unwrap_or_default(); - p.sections.push(Section { name: s.attr("name").unwrap_or("").to_string(), slides }); - } - } - let kept: String = ext - .children_named("ext") - .filter(|e| e.attr("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)) - .map(|e| pres_part.keep(e)) - .collect(); - if !kept.is_empty() { - p.raw_ext = Some(kept); +/// The slide's notes text, read once per notes part. +fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap) -> Option { + let n = part.rels.first_of("notesSlide")?; + let key = opc::norm(&n.target); + if let Some(cached) = cache.get(&key) { + return Some(cached.clone()); + } + let pkg = imp.pkg; + let nd = load(pkg, &n.target)?; + let np = Part::new(pkg, &n.target, &nd); + let notes = read_notes(imp, &np, &nd.root); + cache.insert(key, notes.clone()); + Some(notes) +} + +/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint +/// writes both): the modern one wins, its flattened legacy copy is skipped. +fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) { + let mut parts: Vec<(Doc, bool)> = vec![]; + for c in part.rels.all_of("comments") { + if let Some(cd) = pkg.xml(&c.target) { + // The content type names the modern part; a package that types it only through a + // Default entry instead of an Override is still modern when its root is p188. + let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188"; + parts.push((cd, modern)); } } - p.tags = tags(&imp, &pres_part, pres_el); - // Custom shows. - if let Some(l) = pres_el.child("custShowLst") { - for cs in l.children_named("custShow") { - let slides = cs - .child("sldLst") - .map(|l| { - l.children_named("sld") - .filter_map(|s| pres_part.rels.target(s.attr("r:id").unwrap_or(""))) - .filter_map(|t| imp.slide_by_part.get(&opc::norm(t)).copied()) - .collect() - }) + let has_modern = parts.iter().any(|(_, m)| *m); + for (cd, modern) in parts { + if has_modern && !modern { + continue; + } + slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern)); + } +} + +/// Sections, and the extension elements the model does not understand (kept verbatim). +fn read_sections(p: &mut Presentation, pres_part: &Part, pres_el: &El, file_slide_ids: &HashMap) { + let Some(ext) = pres_el.child("extLst") else { return }; + if let Some(sl) = ext.find("sectionLst") { + for s in sl.children_named("section").take(10_000) { + let slides = s + .child("sldIdLst") + .map(|l| l.children_named("sldId").filter_map(|x| x.u32("id").and_then(|i| file_slide_ids.get(&i).copied())).collect()) .unwrap_or_default(); - p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); + p.sections.push(Section { name: s.attr("name").unwrap_or("").to_string(), slides }); } } - // Drawing guides (eighths of a point). + let kept: String = + ext.children_named("ext").filter(|e| e.attr("uri") != Some(crate::SECTION_EXT_URI) && !has_rel_refs(e)).map(|e| pres_part.keep(e)).collect(); + if !kept.is_empty() { + p.raw_ext = Some(kept); + } +} + +fn read_custom_shows(p: &mut Presentation, imp: &Imp, pres_part: &Part, pres_el: &El) { + let Some(l) = pres_el.child("custShowLst") else { return }; + for cs in l.children_named("custShow") { + let slides = cs + .child("sldLst") + .map(|l| { + l.children_named("sld") + .filter_map(|s| pres_part.rels.target(s.attr("r:id").unwrap_or(""))) + .filter_map(|t| imp.slide_by_part.get(&opc::norm(t)).copied()) + .collect() + }) + .unwrap_or_default(); + p.custom_shows.push(CustomShow { name: cs.attr("name").unwrap_or("").to_string(), slides }); + } +} + +/// Drawing guides (eighths of a point) and slide show settings. +fn read_guides_and_show(p: &mut Presentation, imp: &Imp, pres_part: &Part) { + let pkg = imp.pkg; if let Some(r) = pres_part.rels.first_of("viewProps") && let Some(d) = pkg.xml(&r.target) && let Some(lst) = d.root.find("slideViewPr").and_then(|v| v.find("guideLst")) @@ -628,71 +706,76 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE .take(1000) .collect(); } - // Show settings. if let Some(r) = pres_part.rels.first_of("presProps") && let Some(d) = pkg.xml(&r.target) && let Some(sp) = d.root.child("showPr") { p.show = read_show(sp, &imp.custom_show_names); } - // Notes master. - if let Some(r) = pres_part.rels.first_of("notesMaster") - && let Some(d) = load(&pkg, &r.target) - { - let part = Part::new(&pkg, &r.target, &d); - let mut ctx = shapes::IdCtx::default(); - let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), ..Default::default() }; - if let Some(t) = part.rels.first_of("theme") - && let Some(td) = pkg.xml(&t.target) - { - let tp = Part::new(&pkg, &t.target, &td); - m.theme = theme::read_theme(&mut imp, &tp, &td.root); +} + +/// Theme, background, shapes and colour map shared by the notes and handout masters. +fn read_aux_master(imp: &mut Imp, part: &Part, doc: &Doc, m: &mut Master) { + let mut ctx = shapes::IdCtx::default(); + if let Some(csld) = doc.root.child("cSld") { + m.background = background(imp, part, csld); + if let Some(tree) = csld.child("spTree") { + m.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } - if let Some(csld) = d.root.child("cSld") { - m.background = background(&mut imp, &part, csld); - if let Some(tree) = csld.child("spTree") { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); - } - } - if let Some(cm) = d.root.child("clrMap") { - m.color_map = color_map(cm); - } - // Notes text styles are the body's list style; slide masters use title/body/other instead. - if let Some(ns) = d.root.child("notesStyle") { - m.body_style = dml::list_style(&mut imp, &part, ns); - } - p.notes_master = Some(Arc::new(m)); } - // Handout master (header, footer, date and slide number for printed handouts). - if let Some(r) = pres_part.rels.first_of("handoutMaster") - && let Some(d) = load(&pkg, &r.target) - { - let part = Part::new(&pkg, &r.target, &d); - let mut ctx = shapes::IdCtx::default(); - let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; - if let Some(t) = part.rels.first_of("theme") - && let Some(td) = pkg.xml(&t.target) - { - let tp = Part::new(&pkg, &t.target, &td); - m.theme = theme::read_theme(&mut imp, &tp, &td.root); - } else if let Some(first) = p.masters.first() { - m.theme = first.theme.clone(); - } - if let Some(csld) = d.root.child("cSld") { - if let Some(n) = csld.attr("name").filter(|s| !s.is_empty()) { - m.name = n.to_string(); - } - m.background = background(&mut imp, &part, csld); - if let Some(tree) = csld.child("spTree") { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); - } - } - if let Some(cm) = d.root.child("clrMap") { - m.color_map = color_map(cm); - } - p.handout_master = Some(Arc::new(m)); + if let Some(cm) = doc.root.child("clrMap") { + m.color_map = color_map(cm); } - // Document properties. +} + +/// Reads the part's own theme into the master, if it has one. +fn read_master_theme(imp: &mut Imp, part: &Part, m: &mut Master) -> bool { + let pkg = imp.pkg; + if let Some(t) = part.rels.first_of("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(pkg, &t.target, &td); + m.theme = theme::read_theme(imp, &tp, &td.root); + return true; + } + false +} + +fn read_notes_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first_of("notesMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Notes Master".into(), ..Default::default() }; + read_master_theme(imp, &part, &mut m); + read_aux_master(imp, &part, &d, &mut m); + // Notes text styles are the body's list style; slide masters use title/body/other instead. + if let Some(ns) = d.root.child("notesStyle") { + m.body_style = dml::list_style(imp, &part, ns); + } + p.notes_master = Some(Arc::new(m)); +} + +/// Handout master (header, footer, date and slide number for printed handouts). +fn read_handout_master(imp: &mut Imp, p: &mut Presentation, pres_part: &Part) { + let pkg = imp.pkg; + let Some(r) = pres_part.rels.first_of("handoutMaster") else { return }; + let Some(d) = load(pkg, &r.target) else { return }; + let part = Part::new(pkg, &r.target, &d); + let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; + if !read_master_theme(imp, &part, &mut m) + && let Some(first) = p.masters.first() + { + m.theme = first.theme.clone(); + } + if let Some(n) = d.root.child("cSld").and_then(|c| c.attr("name")).filter(|s| !s.is_empty()) { + m.name = n.to_string(); + } + read_aux_master(imp, &part, &d, &mut m); + p.handout_master = Some(Arc::new(m)); +} + +fn read_document_properties(p: &mut Presentation, pkg: &Package, root_rels: &Rels) { if let Some(r) = root_rels.first_of("core-properties") && let Some(d) = pkg.xml(&r.target) { @@ -703,17 +786,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec), PptxE { p.props.company = d.root.child("Company").map(|c| c.text()).unwrap_or_default(); } - - p.kept_parts = kept_parts(&pkg, &pres_path); - - inherit_placeholder_geometry(&mut p); - default_text_anchor(&mut p); - p.header_footer = header_footer(&p); - p.embedded_fonts = read_embedded_fonts(&mut imp, &pres_part, pres_el); - p.media = std::mem::take(&mut imp.media); - p.next_id = imp.next; - p.fix_next_id(); - Ok((p, lost_slides)) } /// Embedded font faces in one file. Past this, further typefaces are dropped. diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 76158af3b..540aaff1e 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -129,167 +129,22 @@ fn chart_within(root: &El, mut budget: usize) -> Option { // (a combo chart's line on the right) is on the secondary axis. let val_map: std::collections::HashMap<&str, bool> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect(); - // A group's value axis: its vertical one when it has two (scatter), else its one. - let group_val_axis = |g: &El| { - let ids: Vec<(&str, bool)> = - g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); - ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) - }; - // The primary value axis is the one on the left (bottom for bar charts); the secondary one - // sits on the right or top, or crosses the category axis at its maximum. - let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); - let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect(); - let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect(); - let primary_val: Option = - used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned(); + let primary_val = primary_value_axis(pa, &val_map); for g in pa.elements() { let name = g.local(); if !name.ends_with("Chart") { continue; } - let grouping = g.child_val("grouping").unwrap_or("clustered"); - let kind = match name { - "barChart" | "bar3DChart" => { - let bar = g.child_val("barDir") == Some("bar"); - match (bar, grouping) { - (false, "stacked") => ChartType::StackedColumn, - (false, "percentStacked") => ChartType::PercentColumn, - (false, _) => ChartType::Column, - (true, "stacked") => ChartType::StackedBar, - (true, "percentStacked") => ChartType::PercentBar, - (true, _) => ChartType::Bar, - } - } - "lineChart" | "line3DChart" => { - let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true") - && g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none")); - match grouping { - "stacked" | "percentStacked" => ChartType::StackedLine, - _ if markers => ChartType::LineMarkers, - _ => ChartType::Line, - } - } - "areaChart" | "area3DChart" => { - if matches!(grouping, "stacked" | "percentStacked") { - ChartType::StackedArea - } else { - ChartType::Area - } - } - "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, - "doughnutChart" => ChartType::Doughnut, - "scatterChart" => ChartType::Scatter, - "bubbleChart" => ChartType::Bubble, - "radarChart" => { - if g.child_val("radarStyle") == Some("filled") { - ChartType::FilledRadar - } else { - ChartType::Radar - } - } - "stockChart" => { - c.up_down_bars = g.child("upDownBars").is_some(); - ChartType::Stock - } - "surfaceChart" | "surface3DChart" => ChartType::Surface, - _ => continue, - }; - // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. - c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); - // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), - // not the 219 % / -27 % PowerPoint writes into its new charts. - let bar_group = g.is("barChart") || g.is("bar3DChart"); - if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { - c.gap_width = (v / 100.0).clamp(0.0, 5.0); - } else if bar_group { - c.gap_width = 1.5; - } - if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { - c.overlap = (v / 100.0).clamp(-1.0, 1.0); - } else if bar_group { - c.overlap = 0.0; - } - if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { - c.hole_size = (v / 100.0).clamp(0.0, 0.9); - } - // `` without a val attribute means true, as in the schema's default. - // The setting belongs to this plot group: a group that asks for no labels keeps them - // off, even when another group of a combo chart asks for them (#868). - let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); - if group_labels == Some(true) { - c.data_labels = true; + let Some(kind) = group_chart_type(name, g) else { continue }; + if name == "stockChart" { + c.up_down_bars = g.child("upDownBars").is_some(); } + let group_labels = read_group_settings(g, &mut c); kinds.push(kind); - let val_axis = group_val_axis(g); + let val_axis = group_value_axis(&val_map, g); let secondary = val_axis.is_some() && val_axis != primary_val; for s in g.children_named("ser").take(1000) { - let mut ser = Series { - name: s.child("tx").map(|t| strings(t, &mut budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), - fill: simple_fill(s.child("spPr")), - line: simple_line(s.child("spPr")), - smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), - marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), - kind: Some(kind), - secondary_axis: secondary, - ..Default::default() - }; - // `` without a val attribute means true, as in the schema's default. - if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { - ser.data_labels = Some(d); - } else if let Some(false) = group_labels { - // The group asks for no labels and the series does not say otherwise. - ser.data_labels = Some(false); - } - for t in s.children_named("trendline").take(16) { - if let Some(t) = trendline(t) { - ser.trendlines.push(t); - } - } - // Data labels' number format: the series' labels', the group's, else the source data's. - let label_fmt = |d: Option<&El>| { - d.and_then(|d| d.child("numFmt")) - .filter(|n| n.attr("sourceLinked") != Some("1")) - .and_then(|n| n.attr("formatCode")) - .map(str::to_string) - }; - let source = - s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); - ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()); - for dp in s.children_named("dPt") { - if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { - ser.point_fills.push((i as usize, f)); - } - } - if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { - let cats = strings(cat, &mut budget); - if matches!(kind, ChartType::Scatter | ChartType::Bubble) { - ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); - } - if c.categories.is_empty() { - c.categories = cats; - } - } - if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { - ser.values = numbers(v, &mut budget); - } - // Error bars on the values: a scatter chart's y bars, any other chart's only ones. - if let Some(e) = s - .children_named("errBars") - .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble))) - { - let d = ErrorBars::default(); - ser.error_bars = Some(ErrorBars { - bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), - val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), - value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), - plus: e.child("plus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), - minus: e.child("minus").map(|p| numbers(p, &mut budget)).unwrap_or_default(), - end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), - }); - } - if let Some(b) = s.child("bubbleSize") { - ser.sizes = numbers(b, &mut budget); - } + let ser = read_series(s, g, (kind, secondary, group_labels), &mut c.categories, &mut budget); c.series.push(ser); } } @@ -305,11 +160,189 @@ fn chart_within(root: &El, mut budget: usize) -> Option { s.secondary_axis = false; } } - // Axes. c.lang = root.child_val("lang").map(str::to_string); + let (val_ax, cat_ax) = read_axes(&mut c, pa, &primary_val); + read_text_styles(&mut c, root, ch, val_ax, cat_ax); + Some(c) +} + +/// A group's value axis: its vertical one when it has two (scatter), else its one. +fn group_value_axis(val_map: &std::collections::HashMap<&str, bool>, g: &El) -> Option { + let ids: Vec<(&str, bool)> = + g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_map.get(id).copied().map(|v| (id, v))).collect(); + ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string()) +} + +/// The primary value axis is the one on the left (bottom for bar charts); the secondary one +/// sits on the right or top, or crosses the category axis at its maximum. +fn primary_value_axis(pa: &El, val_map: &std::collections::HashMap<&str, bool>) -> Option { + let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t")); + let val_axes_by_id: std::collections::HashMap<&str, &El> = pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, a))).collect(); + let used: Vec = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(|g| group_value_axis(val_map, g)).collect(); + used.iter().find(|id| val_axes_by_id.get(id.as_str()).is_some_and(|a| !secondary_placed(a))).or(used.first()).cloned() +} + +/// The chart type of a plot group (`name` is its element name), or `None` for an unknown group. +fn group_chart_type(name: &str, g: &El) -> Option { + let grouping = g.child_val("grouping").unwrap_or("clustered"); + Some(match name { + "barChart" | "bar3DChart" => { + let bar = g.child_val("barDir") == Some("bar"); + match (bar, grouping) { + (false, "stacked") => ChartType::StackedColumn, + (false, "percentStacked") => ChartType::PercentColumn, + (false, _) => ChartType::Column, + (true, "stacked") => ChartType::StackedBar, + (true, "percentStacked") => ChartType::PercentBar, + (true, _) => ChartType::Bar, + } + } + "lineChart" | "line3DChart" => { + let markers = g.child_val("marker").is_some_and(|v| v == "1" || v == "true") + && g.children_named("ser").all(|s| s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")) != Some("none")); + match grouping { + "stacked" | "percentStacked" => ChartType::StackedLine, + _ if markers => ChartType::LineMarkers, + _ => ChartType::Line, + } + } + "areaChart" | "area3DChart" => { + if matches!(grouping, "stacked" | "percentStacked") { + ChartType::StackedArea + } else { + ChartType::Area + } + } + "pieChart" | "pie3DChart" | "ofPieChart" => ChartType::Pie, + "doughnutChart" => ChartType::Doughnut, + "scatterChart" => ChartType::Scatter, + "bubbleChart" => ChartType::Bubble, + "radarChart" => { + if g.child_val("radarStyle") == Some("filled") { + ChartType::FilledRadar + } else { + ChartType::Radar + } + } + "stockChart" => ChartType::Stock, + "surfaceChart" | "surface3DChart" => ChartType::Surface, + _ => return None, + }) +} + +/// Chart-wide settings a plot group carries (vary colours, gap, overlap, hole, data labels). +/// Returns the group's own data-label setting. +fn read_group_settings(g: &El, c: &mut Chart) -> Option { + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); + if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { + c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; + } + if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { + c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; + } + if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { + c.hole_size = (v / 100.0).clamp(0.0, 0.9); + } + // `` without a val attribute means true, as in the schema's default. + // The setting belongs to this plot group: a group that asks for no labels keeps them + // off, even when another group of a combo chart asks for them (#868). + let group_labels = g.path(&["dLbls", "showVal"]).map(|v| v.bool("val").unwrap_or(true)); + if group_labels == Some(true) { + c.data_labels = true; + } + group_labels +} + +/// Data labels' number format: the series' labels', the group's, else the source data's. +fn label_number_format(s: &El, g: &El) -> Option { + let label_fmt = |d: Option<&El>| { + d.and_then(|d| d.child("numFmt")).filter(|n| n.attr("sourceLinked") != Some("1")).and_then(|n| n.attr("formatCode")).map(str::to_string) + }; + let source = s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text()); + label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty()) +} + +/// Error bars on the values: a scatter chart's y bars, any other chart's only ones. +fn read_error_bars(s: &El, kind: ChartType, budget: &mut usize) -> Option { + let e = s + .children_named("errBars") + .find(|e| e.child_val("errDir").is_none_or(|d| d == "y" || !matches!(kind, ChartType::Scatter | ChartType::Bubble)))?; + let d = ErrorBars::default(); + Some(ErrorBars { + bar_type: e.child_val("errBarType").unwrap_or("both").to_string(), + val_type: e.child_val("errValType").unwrap_or("fixedVal").to_string(), + value: e.child_val("val").and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()).unwrap_or(d.value), + plus: e.child("plus").map(|p| numbers(p, budget)).unwrap_or_default(), + minus: e.child("minus").map(|p| numbers(p, budget)).unwrap_or_default(), + end_cap: e.child("noEndCap").and_then(|v| v.bool("val")) != Some(true), + }) +} + +/// One `c:ser` of group `g`. `group` is (chart type, on the secondary axis, the group's data-label +/// setting); the first series with categories sets `categories`. +fn read_series(s: &El, g: &El, group: (ChartType, bool, Option), categories: &mut Vec, budget: &mut usize) -> Series { + let (kind, secondary, group_labels) = group; + let mut ser = Series { + name: s.child("tx").map(|t| strings(t, budget)).and_then(|v| v.into_iter().next()).unwrap_or_default(), + fill: simple_fill(s.child("spPr")), + line: simple_line(s.child("spPr")), + smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false), + marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from), + kind: Some(kind), + secondary_axis: secondary, + ..Default::default() + }; + // `` without a val attribute means true, as in the schema's default. + if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).map(|v| v.bool("val").unwrap_or(true)) { + ser.data_labels = Some(d); + } else if let Some(false) = group_labels { + // The group asks for no labels and the series does not say otherwise. + ser.data_labels = Some(false); + } + for t in s.children_named("trendline").take(16) { + if let Some(t) = trendline(t) { + ser.trendlines.push(t); + } + } + ser.number_format = label_number_format(s, g); + for dp in s.children_named("dPt") { + if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) { + ser.point_fills.push((i as usize, f)); + } + } + if let Some(cat) = s.child("cat").or_else(|| s.child("xVal")) { + let cats = strings(cat, budget); + if matches!(kind, ChartType::Scatter | ChartType::Bubble) { + ser.x_values = cats.iter().map(|v| v.trim().parse::().ok()).collect(); + } + if categories.is_empty() { + *categories = cats; + } + } + if let Some(v) = s.child("val").or_else(|| s.child("yVal")) { + ser.values = numbers(v, budget); + } + ser.error_bars = read_error_bars(s, kind, budget); + if let Some(b) = s.child("bubbleSize") { + ser.sizes = numbers(b, budget); + } + ser +} + +/// Axis scaling, titles, number formats and visibility. Returns the primary value axis and the +/// category axis. +fn read_axes<'a>(c: &mut Chart, pa: &'a El, primary_val: &Option) -> (Option<&'a El>, Option<&'a El>) { let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r")); let by_id = |id: &Option| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref()); - let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); + let val_ax = by_id(primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last()); let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()); // An axis' number format: its own unless linked to the source (`sourceLinked="1"`, or no // c:numFmt at all), in which case the data's format of the first series plotted on it. @@ -357,6 +390,11 @@ fn chart_within(root: &El, mut budget: usize) -> Option { c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } + (val_ax, cat_ax) +} + +/// Text sizes and colours as the file sets them. +fn read_text_styles(c: &mut Chart, root: &El, ch: &El, val_ax: Option<&El>, cat_ax: Option<&El>) { // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); @@ -364,7 +402,6 @@ fn chart_within(root: &El, mut budget: usize) -> Option { // Text colour likewise: the axes' and legend's, else the whole chart's. c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); - Some(c) } fn trend_num(e: &El, k: &str) -> Option { diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index e96fcd222..767b7dc02 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -631,18 +631,14 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph { let text: String = e.child("t").map(|t| t.text()).unwrap_or_default(); push_text_runs(&mut para, props, &text); } - "br" => { - if para.runs.len() < MAX_PARAGRAPH_RUNS { - let props = run_props(imp, part, e); - para.runs.push(Run { text: String::new(), props, kind: RunKind::Break }); - } + "br" if para.runs.len() < MAX_PARAGRAPH_RUNS => { + let props = run_props(imp, part, e); + para.runs.push(Run { text: String::new(), props, kind: RunKind::Break }); } - "fld" => { - if para.runs.len() < MAX_PARAGRAPH_RUNS { - let props = run_props(imp, part, e); - let text = e.child("t").map(|t| t.text()).unwrap_or_default(); - para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } }); - } + "fld" if para.runs.len() < MAX_PARAGRAPH_RUNS => { + let props = run_props(imp, part, e); + let text = e.child("t").map(|t| t.text()).unwrap_or_default(); + para.runs.push(Run { text, props, kind: RunKind::Field { field: e.attr("type").unwrap_or("").to_string() } }); } "m" => push_math_run(&mut para, part, e), "endParaRPr" => para.end_props = rpr(imp, part, e), diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write.rs similarity index 100% rename from crates/pptx/src/write/mod.rs rename to crates/pptx/src/write.rs diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 04ae042ef..28e62fe98 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -1,7 +1,7 @@ //! Chart parts: a `c:chartSpace` with cached categories and values. //! An unchanged chart also keeps the embedded workbook and the other parts it points at. -use deckcraft_model::chart::{Chart, ChartType, Series, Trendline, TrendlineType}; +use deckcraft_model::chart::{Chart, ChartType, ErrorBars, Series, Trendline, TrendlineType}; use deckcraft_model::style::Fill; use crate::opc::{NS_A, NS_C, NS_R}; @@ -204,6 +204,48 @@ fn group_of(k: ChartType) -> Group { } } +fn error_bars(w: &mut W, e: &ErrorBars, g: Group) { + let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); + w.open0("c:errBars"); + if matches!(g, Group::Scatter | Group::Bubble) { + w.val("c:errDir", "y"); + } + w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); + let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); + w.val("c:errValType", vt); + w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); + if vt == "cust" { + w.open0("c:plus"); + num_lit(w, &e.plus); + w.close("c:plus"); + w.open0("c:minus"); + num_lit(w, &e.minus); + w.close("c:minus"); + } else if e.value.is_finite() { + w.val("c:val", fmt_num(e.value)); + } + w.close("c:errBars"); +} + +/// Per-point fills of pie, doughnut and bar series. +fn point_fills(w: &mut W, s: &Series, g: Group) { + if !matches!(g, Group::Pie | Group::Doughnut | Group::Bar) { + return; + } + for (idx, f) in s.point_fills.iter().take(1000) { + w.open0("c:dPt"); + w.val("c:idx", idx); + if g == Group::Bar { + w.val("c:invertIfNegative", 0); + } + if g != Group::Bar { + w.val("c:bubble3D", 0); + } + sp_pr(w, Some(f), None); + w.close("c:dPt"); + } +} + fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) { w.open0("c:ser"); w.val("c:idx", i); @@ -253,20 +295,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.close("c:marker"); } } - if g == Group::Pie || g == Group::Doughnut || g == Group::Bar { - for (idx, f) in s.point_fills.iter().take(1000) { - w.open0("c:dPt"); - w.val("c:idx", idx); - if g == Group::Bar { - w.val("c:invertIfNegative", 0); - } - if g != Group::Bar { - w.val("c:bubble3D", 0); - } - sp_pr(w, Some(f), None); - w.close("c:dPt"); - } - } + point_fills(w, s, g); if let Some(d) = s.data_labels { data_labels(w, c, d); } @@ -279,26 +308,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) let n = c.categories.len().max(s.values.len()); let last = n + 1; if let Some(e) = &s.error_bars { - let word = |v: &str, ok: &[&'static str], d: &'static str| ok.iter().copied().find(|o| *o == v).unwrap_or(d); - w.open0("c:errBars"); - if matches!(g, Group::Scatter | Group::Bubble) { - w.val("c:errDir", "y"); - } - w.val("c:errBarType", word(&e.bar_type, &["both", "plus", "minus"], "both")); - let vt = word(&e.val_type, &["fixedVal", "percentage", "stdDev", "stdErr", "cust"], "fixedVal"); - w.val("c:errValType", vt); - w.val("c:noEndCap", if e.end_cap { 0 } else { 1 }); - if vt == "cust" { - w.open0("c:plus"); - num_lit(w, &e.plus); - w.close("c:plus"); - w.open0("c:minus"); - num_lit(w, &e.minus); - w.close("c:minus"); - } else if e.value.is_finite() { - w.val("c:val", fmt_num(e.value)); - } - w.close("c:errBars"); + error_bars(w, e, g); } if matches!(g, Group::Scatter | Group::Bubble) { let xs: Vec> = if s.x_values.is_empty() { (1..=s.values.len()).map(|v| Some(v as f64)).collect() } else { s.x_values.clone() }; diff --git a/crates/pptx/tests/it/corpus_charts.rs b/crates/pptx/tests/it/corpus_charts.rs index ff1e6424e..f77d93070 100644 --- a/crates/pptx/tests/it/corpus_charts.rs +++ b/crates/pptx/tests/it/corpus_charts.rs @@ -159,6 +159,11 @@ fn chart_table_and_text_corpus_round_trips() { assert_eq!(a.data_labels, b.data_labels); } + merged_table_survives(slide); + text_columns_and_warp_survive(slide); +} + +fn merged_table_survives(slide: &Slide) { let ShapeKind::Table(t) = &by_name(slide, "Merged table").kind else { panic!("not a table") }; let anchor = t.cell(0, 0).expect("anchor"); assert_eq!((anchor.grid_span, anchor.row_span), (2, 2)); @@ -171,7 +176,9 @@ fn chart_table_and_text_corpus_round_trips() { assert_eq!(diag.diag_down, Some(Line::solid(ColorRef::rgb(Rgba::rgb(180, 40, 40)), 1.5))); assert_eq!(diag.diag_up, Some(Line::solid(ColorRef::rgb(Rgba::rgb(20, 90, 160)), 2.0))); assert_eq!(plain(&diag.text), "Diag"); +} +fn text_columns_and_warp_survive(slide: &Slide) { let cols = by_name(slide, "Columns").text.as_ref().expect("columns"); assert_eq!(plain(cols), "Two columns\nof vertical text"); assert_eq!(cols.body.columns, Some(3)); diff --git a/crates/pptx/tests/it/corpus_elements.rs b/crates/pptx/tests/it/corpus_elements.rs index 7d3a41da9..f6bae69ae 100644 --- a/crates/pptx/tests/it/corpus_elements.rs +++ b/crates/pptx/tests/it/corpus_elements.rs @@ -215,7 +215,14 @@ fn assert_deck(q: &Presentation) { let inherited = resolve::run(&ctx, body, ¶s[1], ¶s[1].runs[0].props); assert_eq!(inherited.size, Some(28.0), "the second paragraph takes the layout list style"); assert_eq!(inherited.font.as_deref(), Some("Source Serif 4")); + assert_group(q); + assert_comments(q); + assert_click_links(q); + assert_custom_show_links(q); + assert_custom_shows(q); +} +fn assert_group(q: &Presentation) { let cluster = shape(q, 0, "Cluster"); let g = cluster.xfrm.expect("group box"); assert_eq!((g.x, g.y, g.w, g.h, g.rot, g.flip_h), (80.0, 40.0, 220.0, 140.0, 25.0, true)); @@ -225,13 +232,17 @@ fn assert_deck(q: &Presentation) { assert_eq!(gem.text.as_ref().map(|t| t.text()), Some("Tilted".into())); let gx = gem.xfrm.expect("gem"); assert_eq!((gx.x, gx.y, gx.w, gx.h, gx.rot, gx.flip_v), (12.0, 18.0, 80.0, 40.0, 20.0, true)); +} +fn assert_comments(q: &Presentation) { let c = &q.slides[0].comments; assert_eq!(c.len(), 1); assert_eq!((c[0].author.as_str(), c[0].initials.as_str(), c[0].text.as_str()), ("Mina", "ML", "Check the quote")); assert_eq!(c[0].date, "2026-03-01T09:30:00.000"); assert_eq!((c[0].x, c[0].y), (36.0, 48.0)); +} +fn assert_click_links(q: &Presentation) { let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); match click("Site").action { Action::Url { url } => assert_eq!(url, "https://example.com/deck"), @@ -259,7 +270,10 @@ fn assert_deck(q: &Presentation) { assert!(matches!(hover.action, Action::PreviousSlide)); assert!(hover.stop_sound); assert_eq!(hover.tooltip, "Back one"); +} +fn assert_custom_show_links(q: &Presentation) { + let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click")); match click("To board").action { Action::CustomShow { name, returns } => { assert_eq!(name, "Board"); @@ -276,7 +290,9 @@ fn assert_deck(q: &Presentation) { } other => panic!("{other:?}"), } +} +fn assert_custom_shows(q: &Presentation) { assert_eq!(q.custom_shows.len(), 2); assert_eq!(q.custom_shows[0].name, "Short"); assert_eq!(q.custom_shows[0].slides, vec![q.slides[0].id]); diff --git a/crates/pptx/tests/it/corpus_media.rs b/crates/pptx/tests/it/corpus_media.rs index 6ffd14aaf..ce0494d3f 100644 --- a/crates/pptx/tests/it/corpus_media.rs +++ b/crates/pptx/tests/it/corpus_media.rs @@ -198,76 +198,92 @@ fn object_corpus_round_trips() { let twice = round(&once); for q in [&once, &twice] { - let diagram = by_name(q, "Diagram"); - let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; - assert_eq!(label, "SmartArt"); - assert!(xml.contains("Quarter"), "{xml}"); - assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); - - let embedded = by_name(q, "Embedded"); - let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; - assert_eq!(label, "DeckCraft.Note.1"); - assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); - assert!(!xml.contains("r:id"), "an embedding part is issue #203"); - - let block_shape = by_name(q, "Block"); - assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); - assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); - assert_eq!(media_bytes(q, "Block"), block); - let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; - let block_media = q.media(*media).expect("block media"); - assert_eq!(block_media.content_type, "model/gltf-binary"); - assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); - - let globe_shape = by_name(q, "Globe"); - let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; - assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); - assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe.as_slice()); - assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster.as_slice()); - - let narration = by_name(q, "Narration"); - let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; - assert!(!m.video); - assert_eq!(m.trim_start_ms, 400); - assert_eq!(m.trim_end_ms, 2600); - assert_eq!(m.fade_in_ms, 80); - assert_eq!(m.fade_out_ms, 120); - assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); - assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); - - let film = by_name(q, "Film"); - let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; - assert!(m.video); - assert_eq!(m.trim_start_ms, 250); - assert_eq!(m.trim_end_ms, 9000); - assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); - assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); - - assert_field(q, "Slide number", "slidenum", "4"); - assert_field(q, "Date", "datetime4", "14 October 2026"); - assert_eq!(q.first_slide_number, 4); - - assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); - let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); - assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); - assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); - assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); - - assert_ink(by_name(q, "Sketch")); - - let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); - let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); - let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); - assert_eq!(fade.repeat, 4); - assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); - assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); - assert_eq!(fade.shape, by_name(q, "Fader").id); - assert_eq!(pulse.repeat, u32::MAX); - assert_eq!(pulse.class, AnimClass::Emphasis); - assert!(pulse.trigger.is_none()); + opaque_parts_survive(q); + models_survive(q, &block, &globe, &poster); + media_clips_survive(q); + fields_and_sections_survive(q); + ink_and_timing_survive(q); } } +fn opaque_parts_survive(q: &Presentation) { + let diagram = by_name(q, "Diagram"); + let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; + assert_eq!(label, "SmartArt"); + assert!(xml.contains("Quarter"), "{xml}"); + assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); + + let embedded = by_name(q, "Embedded"); + let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; + assert_eq!(label, "DeckCraft.Note.1"); + assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); + assert!(!xml.contains("r:id"), "an embedding part is issue #203"); +} + +fn models_survive(q: &Presentation, block: &[u8], globe: &[u8], poster: &[u8]) { + let block_shape = by_name(q, "Block"); + assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); + assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); + assert_eq!(media_bytes(q, "Block"), block); + let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; + let block_media = q.media(*media).expect("block media"); + assert_eq!(block_media.content_type, "model/gltf-binary"); + assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); + + let globe_shape = by_name(q, "Globe"); + let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; + assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); + assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe); + assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster); +} + +fn media_clips_survive(q: &Presentation) { + let narration = by_name(q, "Narration"); + let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; + assert!(!m.video); + assert_eq!(m.trim_start_ms, 400); + assert_eq!(m.trim_end_ms, 2600); + assert_eq!(m.fade_in_ms, 80); + assert_eq!(m.fade_out_ms, 120); + assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); + assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); + + let film = by_name(q, "Film"); + let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; + assert!(m.video); + assert_eq!(m.trim_start_ms, 250); + assert_eq!(m.trim_end_ms, 9000); + assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); + assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); +} + +fn fields_and_sections_survive(q: &Presentation) { + assert_field(q, "Slide number", "slidenum", "4"); + assert_field(q, "Date", "datetime4", "14 October 2026"); + assert_eq!(q.first_slide_number, 4); + + assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); + let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); + assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); + assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); + assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); +} + +fn ink_and_timing_survive(q: &Presentation) { + assert_ink(by_name(q, "Sketch")); + + let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); + let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); + let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); + assert_eq!(fade.repeat, 4); + assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); + assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); + assert_eq!(fade.shape, by_name(q, "Fader").id); + assert_eq!(pulse.repeat, u32::MAX); + assert_eq!(pulse.class, AnimClass::Emphasis); + assert!(pulse.trigger.is_none()); +} + /// An exported deck with `parts` added (or replaced) and `edit` applied to every XML part. fn with_parts(parts: &[(&str, Vec)], edit: impl Fn(&str, String) -> String) -> Vec { let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); diff --git a/crates/pptx/tests/it/corpus_style.rs b/crates/pptx/tests/it/corpus_style.rs index d552002fa..6eac7bddd 100644 --- a/crates/pptx/tests/it/corpus_style.rs +++ b/crates/pptx/tests/it/corpus_style.rs @@ -189,9 +189,14 @@ fn style_kinds_round_trip() { .as_slice() ) ); + text_run_effects_survive(&p, &once, &twice); + theme_and_bevel_survive(&once, &twice); + connector_glue_survives(&p, &once, &twice); +} - let got = run_of(&once); - let expect = run_of(&p); +fn text_run_effects_survive(p: &Presentation, once: &Presentation, twice: &Presentation) { + let got = run_of(once); + let expect = run_of(p); assert_eq!(got.fill, expect.fill, "text gradient"); assert_eq!(got.outline, expect.outline, "text outline"); assert_eq!(got.shadow, expect.shadow, "text shadow"); @@ -199,17 +204,19 @@ fn style_kinds_round_trip() { assert_eq!(got.inner_shadow, expect.inner_shadow, "text inner shadow"); assert_eq!(got.reflection, expect.reflection, "text reflection"); assert_eq!(got.soft_edge, expect.soft_edge, "text soft edge"); - assert_eq!(run_of(&twice), run_of(&once)); + assert_eq!(run_of(twice), run_of(once)); assert_eq!( - by_name(&twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), - by_name(&once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) + by_name(twice, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)), + by_name(once, "Styled text").text.as_ref().map(|t| (&t.body, &t.paragraphs[0].props)) ); - assert_eq!(by_name(&once, "Path gradient").fill, by_name(&p, "Path gradient").fill); - assert_eq!(by_name(&once, "Pattern").fill, by_name(&p, "Pattern").fill); - assert_eq!(by_name(&twice, "Path gradient").fill, by_name(&once, "Path gradient").fill); - assert_eq!(by_name(&twice, "Pattern").fill, by_name(&once, "Pattern").fill); + assert_eq!(by_name(once, "Path gradient").fill, by_name(p, "Path gradient").fill); + assert_eq!(by_name(once, "Pattern").fill, by_name(p, "Pattern").fill); + assert_eq!(by_name(twice, "Path gradient").fill, by_name(once, "Path gradient").fill); + assert_eq!(by_name(twice, "Pattern").fill, by_name(once, "Pattern").fill); +} +fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) { let theme = &once.masters[0].theme.format.effects[0]; assert_eq!(theme.glow, Some(glow())); assert_eq!(theme.reflection, Some(reflection())); @@ -220,26 +227,24 @@ fn style_kinds_round_trip() { assert_eq!(theme2.bevel, theme.bevel); assert_eq!(theme2.raw3d, theme.raw3d); - let named = by_name(&once, "Bevel preset").effects.as_ref().expect("effects"); + let named = by_name(once, "Bevel preset").effects.as_ref().expect("effects"); assert_eq!(named.bevel.as_deref(), Some("relaxedInset")); assert!(named.raw3d.as_deref().unwrap_or("").contains("relaxedInset")); - assert_eq!(by_name(&twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); + assert_eq!(by_name(twice, "Bevel preset").effects.as_ref().and_then(|e| e.bevel.as_deref()), Some("relaxedInset")); - let bevel = by_name(&once, "Bevel").effects.as_ref().expect("effects"); + let bevel = by_name(once, "Bevel").effects.as_ref().expect("effects"); assert_eq!(bevel.bevel.as_deref(), Some("angle")); let raw = bevel.raw3d.as_deref().unwrap_or(""); assert!(raw.contains("bevelT") && raw.contains("angle") && raw.contains("scene3d"), "{raw}"); - assert_eq!(by_name(&twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); + assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); +} - let c = by_name(&once, "Connector"); - assert!( - matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(&p) && e == right_of(&p)), - "{:?}", - c.kind - ); - assert_eq!(c.line, by_name(&p, "Connector").line); - assert_eq!(c.geom, by_name(&p, "Connector").geom); - assert_eq!(by_name(&twice, "Connector").kind, c.kind); +fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) { + let c = by_name(once, "Connector"); + assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind); + assert_eq!(c.line, by_name(p, "Connector").line); + assert_eq!(c.geom, by_name(p, "Connector").geom); + assert_eq!(by_name(twice, "Connector").kind, c.kind); } fn left_of(p: &Presentation) -> ShapeId { diff --git a/crates/pptx/tests/it/corpus_themes.rs b/crates/pptx/tests/it/corpus_themes.rs index 15f76ae05..744b27ba0 100644 --- a/crates/pptx/tests/it/corpus_themes.rs +++ b/crates/pptx/tests/it/corpus_themes.rs @@ -148,10 +148,13 @@ fn effects_and_links_survive_a_round_trip() { assert!(matches!(&links[1].1, Some(Hyperlink { action: Action::Url { url }, .. }) if url.starts_with("mailto:"))); let hover = get("Reflected").text.as_ref().and_then(|t| t.paragraphs[0].runs[0].props.hover.clone()); assert!(matches!(hover, Some(Hyperlink { action: Action::Url { .. }, ref tooltip, .. }) if tooltip == "Preview"), "{hover:?}"); + effects_and_links_survive_resave(&a); +} +fn effects_and_links_survive_resave(a: &Presentation) { // Saved and reopened: the same effects and links. - let b = round_trip(&a); - let (ga, gb) = (shapes_named(&a, 0), shapes_named(&b, 0)); + let b = round_trip(a); + let (ga, gb) = (shapes_named(a, 0), shapes_named(&b, 0)); for n in ["Shadowed", "Glowing", "Soft edged", "Reflected"] { assert_eq!(fx(ga(n)), fx(gb(n)), "effects of {n}"); assert_eq!(ga(n).click, gb(n).click, "click of {n}"); @@ -203,24 +206,28 @@ fn two_masters() -> Presentation { p } +fn same_master(ma: &deckcraft_model::Master, mb: &deckcraft_model::Master) { + assert_eq!(ma.name, mb.name); + assert_eq!(ma.theme.name, mb.theme.name); + // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. + let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; + assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); + assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); + assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); + assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); + assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); + assert_eq!(ma.background, mb.background, "background of {}", ma.name); + let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(ma), names(mb)); +} + #[test] fn themes_masters_and_layouts_survive_a_round_trip() { let a = two_masters(); let b = round_trip(&a); assert_eq!(b.masters.len(), 2); for (ma, mb) in a.masters.iter().zip(&b.masters) { - assert_eq!(ma.name, mb.name); - assert_eq!(ma.theme.name, mb.theme.name); - // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. - let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; - assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); - assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); - assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); - assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); - assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); - assert_eq!(ma.background, mb.background, "background of {}", ma.name); - let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); - assert_eq!(names(ma), names(mb)); + same_master(ma, mb); } // Each slide stays on its master's layout. let layout_name = diff --git a/crates/pptx/tests/it/sample.rs b/crates/pptx/tests/it/sample.rs index f42ca2301..f7cbba126 100644 --- a/crates/pptx/tests/it/sample.rs +++ b/crates/pptx/tests/it/sample.rs @@ -10,6 +10,30 @@ fn sample() -> Presentation { s.doc().expect("doc").doc.as_ref().clone() } +fn same_shapes(a: &deckcraft_model::Slide, b: &deckcraft_model::Slide) { + assert_eq!(a.shapes.len(), b.shapes.len(), "shape count on slide '{}'", a.title()); + for (sa, sb) in a.shapes.iter().zip(b.shapes.iter()) { + assert_eq!(sa.kind_name(), sb.kind_name(), "{}", sa.name); + assert_eq!(sa.text.as_ref().map(|t| t.text()), sb.text.as_ref().map(|t| t.text()), "{}", sa.name); + if let (Some(x), Some(y)) = (sa.xfrm, sb.xfrm) { + assert!((x.x - y.x).abs() < 0.01 && (x.w - y.w).abs() < 0.01, "{}: {x:?} vs {y:?}", sa.name); + } + if let (ShapeKind::Table(ta), ShapeKind::Table(tb)) = (&sa.kind, &sb.kind) { + assert_eq!(ta.style, tb.style); + assert_eq!(ta.rows.len(), tb.rows.len()); + } + if let (ShapeKind::Chart(ca), ShapeKind::Chart(cb)) = (&sa.kind, &sb.kind) { + assert_eq!(ca.kind, cb.kind); + assert_eq!(ca.categories, cb.categories); + assert_eq!(ca.series.len(), cb.series.len()); + for (x, y) in ca.series.iter().zip(cb.series.iter()) { + assert_eq!(x.name, y.name); + assert_eq!(x.values, y.values); + } + } + } +} + #[test] fn sample_deck_round_trips() { let p = sample(); @@ -25,27 +49,7 @@ fn sample_deck_round_trips() { for (a, b) in p.slides.iter().zip(q.slides.iter()) { assert_eq!(a.title(), b.title()); assert_eq!(a.notes_text(), b.notes_text()); - assert_eq!(a.shapes.len(), b.shapes.len(), "shape count on slide '{}'", a.title()); - for (sa, sb) in a.shapes.iter().zip(b.shapes.iter()) { - assert_eq!(sa.kind_name(), sb.kind_name(), "{}", sa.name); - assert_eq!(sa.text.as_ref().map(|t| t.text()), sb.text.as_ref().map(|t| t.text()), "{}", sa.name); - if let (Some(x), Some(y)) = (sa.xfrm, sb.xfrm) { - assert!((x.x - y.x).abs() < 0.01 && (x.w - y.w).abs() < 0.01, "{}: {x:?} vs {y:?}", sa.name); - } - if let (ShapeKind::Table(ta), ShapeKind::Table(tb)) = (&sa.kind, &sb.kind) { - assert_eq!(ta.style, tb.style); - assert_eq!(ta.rows.len(), tb.rows.len()); - } - if let (ShapeKind::Chart(ca), ShapeKind::Chart(cb)) = (&sa.kind, &sb.kind) { - assert_eq!(ca.kind, cb.kind); - assert_eq!(ca.categories, cb.categories); - assert_eq!(ca.series.len(), cb.series.len()); - for (x, y) in ca.series.iter().zip(cb.series.iter()) { - assert_eq!(x.name, y.name); - assert_eq!(x.values, y.values); - } - } - } + same_shapes(a, b); assert_eq!(a.transition.as_ref().map(|t| (&t.kind, &t.option)), b.transition.as_ref().map(|t| (&t.kind, &t.option))); assert_eq!(a.animations.len(), b.animations.len(), "animations on '{}'", a.title()); for (x, y) in a.animations.iter().zip(b.animations.iter()) { diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 626c2d91c..cb96dba9f 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -147,18 +147,16 @@ fn split_label(name: &str) -> String { } } -pub(crate) fn label( - ctx: &mut RenderContext, - rctx: &Ctx, - m: Affine, - text: &str, - r: Rect, - size: f64, - align: Align, - anchor: Anchor, - col: Rgba, - bold: bool, -) { +pub(crate) struct LabelStyle { + pub(crate) size: f64, + pub(crate) align: Align, + pub(crate) anchor: Anchor, + pub(crate) col: Rgba, + pub(crate) bold: bool, +} + +pub(crate) fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, style: LabelStyle) { + let LabelStyle { size, align, anchor, col, bold } = style; let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -178,7 +176,14 @@ pub(crate) fn label( } } let tmp = Shape { text: Some(body.clone()), ..Default::default() }; - crate::draw_text(ctx, rctx, &tmp, &body, r, m, &deckcraft_text::NoFields, None); + crate::draw_text( + ctx, + rctx, + &tmp, + &body, + m, + &deckcraft_text::Opts { rect: r, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); } fn fmt_num(v: f64) -> String { @@ -246,15 +251,24 @@ fn axis_ticks((lo, hi, st): (f64, f64, f64)) -> impl Iterator { pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { match c.kind { // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. - ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::Histogram | ChartType::Pareto => { + draw_inner(ctx, rctx, &c.histogram_view(), ChartFrame { transform: m, width: w, height: h, boxes: None }) + } ChartType::BoxWhisker => { let (view, boxes) = crate::chartex::box_view(c); - draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + draw_inner(ctx, rctx, &view, ChartFrame { transform: m, width: w, height: h, boxes: Some(&boxes) }); } - _ => draw_inner(ctx, rctx, c, m, w, h, None), + _ => draw_inner(ctx, rctx, c, ChartFrame { transform: m, width: w, height: h, boxes: None }), } } +struct ChartFrame<'a> { + transform: Affine, + width: f64, + height: f64, + boxes: Option<&'a crate::chartex::Boxes>, +} + /// PowerPoint's default chart text colour: `tx1` with `lumMod` 65000 and `lumOff` 35000, which /// is 595959 on a light theme and a light grey on a dark one. pub fn default_text_color() -> ColorRef { @@ -262,8 +276,8 @@ pub fn default_text_color() -> ColorRef { ColorRef::scheme(SchemeSlot::Tx1).with(ColorTransform::LumMod(65000)).with(ColorTransform::LumOff(35000)) } -#[allow(clippy::too_many_arguments)] -fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, frame: ChartFrame<'_>) { + let ChartFrame { transform: m, width: w, height: h, boxes } = frame; // Text without a colour of its own takes PowerPoint's default, the theme's text colour at // lumMod 65 % / lumOff 35 % (595959 on a light theme, light grey on a dark one), so a chart // on a dark theme is readable (a fixed grey used to vanish there). @@ -289,128 +303,24 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let title_size = c.title_size.unwrap_or(base * 1.4); 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 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false); + label( + ctx, + rctx, + m, + t, + Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), + LabelStyle { size: title_size, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, + ); area.y0 += title_size * 1.7; } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. let waterfall = c.kind == ChartType::Waterfall; // Each legend entry: its name, its key's paint and the key's outline. - let legend_items: Vec<(String, Paint, Option<(Rgba, f64)>)> = if waterfall { - ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { - // A hierarchy's legend names its top-level groups. - c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { - // A map draws its own colour scale. - vec![] - } else if pie { - c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() - } else { - c.series - .iter() - .enumerate() - .map(|(i, s)| { - let k = c.series_kind(s); - let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - if line { - (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) - } else if k.is_bar_like() { - let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { - Paint::Color(series_color(rctx, c, i)) - } else { - paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) - }; - (s.name.clone(), paint, bar_outline(rctx, s)) - } else { - (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) - } - }) - .collect() - }; - if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) { - let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); - let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); - let size = if vertical { - (item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5) - } else { - (item_ws.iter().sum::().min(w * 0.9), base * 1.4) - }; - let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); - area = plot_area; - let (mut x, mut y) = (origin.x, origin.y); - for (name, paint, outline) in &legend_items { - let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); - fill_with(ctx, paint, key, m); - if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { - ctx.set_transform(m); - ctx.set_paint(color(oc, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); - ctx.stroke_path(&key.to_path(0.1)); - } - let tw = name.chars().count() as f64 * base * 0.55 + base; - label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false); - if vertical { - y += base * 1.5; - } else { - x += tw + base * 0.6; - } - } - } + let legend_items = build_legend_items(rctx, c, pie, waterfall); + area = draw_legend(ctx, &LegendDraw { rctx, chart: c, transform: m, items: &legend_items, base, text_color: text_col, width: w }, area); 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.series_data_labels(s) { - 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, - &c.data_label(s, *v), - Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), - base, - Align::Center, - Anchor::Middle, - // On the slices themselves: white, unless the chart's text was given a colour. - if c.text_color.is_some() { text_col } else { 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)); - } + draw_pie(ctx, rctx, c, m, area, base, text_col); return; } if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { @@ -425,23 +335,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() }; - // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. - // On a horizontal bar chart the category axis is the left one. - let (left_title, bottom_title) = - if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; - let band_h = base * 1.6; - if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { - let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); - label(ctx, rctx, m, t, r, base, Align::Center, Anchor::Bottom, text_col, true); - area.y1 -= band_h + base * 0.3; - } - if let Some(t) = left_title.filter(|t| !t.is_empty()) { - let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); - let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); - let half = area.height() / 2.0; - label(ctx, rctx, turned, t, Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), base, Align::Center, Anchor::Middle, text_col, true); - area.x0 += band_h + base * 0.3; - } + area = draw_axis_titles(ctx, rctx, &AxisTitleDrawing { chart: c, transform: m, base, text_color: text_col, horiz }, area); // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; @@ -492,6 +386,265 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, (true, Some((min2, max2, _))) => plot.y1 - (v.max(min2).min(max2) - min2) / positive_span(max2 - min2) * plot.height(), _ => to_v(v), }; + draw_gridlines( + ctx, + &GridlineDrawing { + rctx, + chart: c, + transform: m, + area, + plot, + horiz, + base, + text_color: text_col, + grid_color: grid_col, + min, + max, + step, + log, + primary_ticks: &primary_ticks, + second, + to_v: &to_v, + to_axis: &to_axis, + }, + ); + let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on + // their own side of the data. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); + draw_category_labels( + ctx, + &CategoryLabelDrawing { rctx, chart: c, transform: m, area, plot, horiz, band, slot: &slot, xscale, base, text_color: text_col }, + ); + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(clamp_soft(0.0, min, max)), + }; + // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + let mut err_marks = draw_bars( + ctx, + &BarDrawing { + rctx, + chart: c, + transform: m, + plot, + band, + horiz, + ncat, + bar_kind, + waterfall, + slot: &slot, + to_v: &to_v, + to_axis: &to_axis, + base, + text_color: text_col, + }, + ); + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } + } + draw_lines( + ctx, + &mut LineDrawing { + chart: c, + rctx, + transform: m, + plot, + category_count: ncat, + band, + slot: &slot, + to_x: &to_x, + to_axis: &to_axis, + zero, + base, + error_marks: &mut err_marks, + }, + ); + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, TrendlineLayout { plot, band, slot: &slot_at, xscale, ncat, horiz, base, text_col, to_axis: &to_axis }); + if !err_marks.is_empty() { + let mut p = BezPath::new(); + for &(mid, a, b, cap, end_cap) in &err_marks { + // A horizontal bar chart's error bars run across, its value axis being horizontal. + let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; + p.move_to(pt(a, mid)); + p.line_to(pt(b, mid)); + if end_cap { + for e in [a, b] { + p.move_to(pt(e, mid - cap)); + p.line_to(pt(e, mid + cap)); + } + } + } + ctx.set_transform(m); + ctx.set_paint(color(text_col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&p); + } + // Axis line. + let mut ax = BezPath::new(); + if horiz { + 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); +} + +struct LegendDraw<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + items: &'a [(String, Paint, Option<(Rgba, f64)>)], + base: f64, + text_color: Rgba, + width: f64, +} + +fn draw_legend(ctx: &mut RenderContext, draw: &LegendDraw<'_, '_>, mut area: Rect) -> Rect { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let legend_items = draw.items; + let base = draw.base; + let text_col = draw.text_color; + let width = draw.width; + let Some(pos) = c.legend.as_ref().filter(|_| !legend_items.is_empty()) else { return area }; + let vertical = matches!(pos.as_str(), "l" | "r" | "tr"); + let item_ws: Vec = legend_items.iter().map(|(n, _, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect(); + let size = if vertical { + (item_ws.iter().copied().fold(0.0, f64::max).min(width * 0.4), legend_items.len() as f64 * base * 1.5) + } else { + (item_ws.iter().sum::().min(width * 0.9), base * 1.4) + }; + let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay); + area = plot_area; + let (mut x, mut y) = (origin.x, origin.y); + for (name, paint, outline) in legend_items { + let key = Rect::new(x, y + base * 0.3, x + base * 0.7, y + base); + fill_with(ctx, paint, key, m); + if let Some((oc, ow)) = outline.filter(|o| o.1 > 0.0) { + ctx.set_transform(m); + ctx.set_paint(color(oc, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(ow.min(base * 0.2))); + ctx.stroke_path(&key.to_path(0.1)); + } + let tw = name.chars().count() as f64 * base * 0.55 + base; + label( + ctx, + rctx, + m, + name, + Rect::new(x + base, y, x + base + tw, y + base * 1.3), + LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + ); + if vertical { + y += base * 1.5; + } else { + x += tw + base * 0.6; + } + } + area +} + +fn build_legend_items(rctx: &Ctx, c: &Chart, pie: bool, waterfall: bool) -> Vec<(String, Paint, Option<(Rgba, f64)>)> { + if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + // A hierarchy's legend names its top-level groups. + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else if matches!(c.kind, ChartType::Funnel | ChartType::RegionMap) { + // A map draws its own colour scale. + vec![] + } else if pie { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), Paint::Color(series_color(rctx, c, i)), None)).collect() + } else { + c.series + .iter() + .enumerate() + .map(|(i, s)| { + let k = c.series_kind(s); + let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + if line { + (s.name.clone(), Paint::Color(series_line(rctx, c, i, s)), None) + } else if k.is_bar_like() { + let paint = if c.vary_colors && c.series.len() == 1 && s.fill.is_none() { + Paint::Color(series_color(rctx, c, i)) + } else { + paint_of(rctx, s.fill.as_ref(), series_color(rctx, c, i)) + }; + (s.name.clone(), paint, bar_outline(rctx, s)) + } else { + (s.name.clone(), Paint::Color(series_fill(rctx, c, i, Some(s))), None) + } + }) + .collect() + } +} + +struct GridlineDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + area: Rect, + plot: Rect, + horiz: bool, + base: f64, + text_color: Rgba, + grid_color: Rgba, + min: f64, + max: f64, + step: f64, + log: Option<(f64, (f64, f64, f64))>, + primary_ticks: &'a [f64], + second: Option<(f64, f64, f64)>, + to_v: &'a dyn Fn(f64) -> f64, + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn draw_gridlines(ctx: &mut RenderContext, draw: &GridlineDrawing<'_, '_>) { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let area = draw.area; + let plot = draw.plot; + let horiz = draw.horiz; + let base = draw.base; + let text_col = draw.text_color; + let grid_col = draw.grid_color; + let min = draw.min; + let max = draw.max; + let step = draw.step; + let log = draw.log; + let primary_ticks = draw.primary_ticks; + let second = draw.second; + let to_v = draw.to_v; + let to_axis = draw.to_axis; + let ticks = axis_ticks; // Minor gridlines, under the major ones: five to a major unit. if c.minor_gridlines { let minor = step / 5.0; @@ -515,7 +668,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, ctx.stroke_path(&g); } // Gridlines and value labels. - for &v in &primary_ticks { + for &v in primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -528,11 +681,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, 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, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } else { g.move_to((plot.x0, pos)); @@ -543,11 +692,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base), - base, - Align::Right, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } if c.gridlines { @@ -567,29 +712,39 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &c.axis_label(true, v), Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base), - base, - Align::Left, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, ); } } - let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - // Forecast reserves empty category slots so the line can run past the data. Charts with no - // forecast keep the old band. - let (back, fwd) = forecast_pad(c); - let nspan = ncat as f64 + back + fwd; - let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; - // Where category `i`'s band starts along its axis: left to right, or bottom to top on a - // horizontal bar chart, unless the axis is reversed. The forecast's empty slots stay on - // their own side of the data. - let backwards = horiz != c.cat_reversed; - let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 + fwd } else { i as f64 + back }; - // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its - // own tick labels in place of category names. - let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; - let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / positive_span(hi - lo) * plot.width()); +} + +struct CategoryLabelDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + area: Rect, + plot: Rect, + horiz: bool, + band: f64, + slot: &'a dyn Fn(usize) -> f64, + xscale: Option<(f64, f64, f64)>, + base: f64, + text_color: Rgba, +} + +fn draw_category_labels(ctx: &mut RenderContext, draw: &CategoryLabelDrawing<'_, '_>) { + let rctx = draw.rctx; + let c = draw.chart; + let m = draw.transform; + let area = draw.area; + let plot = draw.plot; + let horiz = draw.horiz; + let band = draw.band; + let slot = draw.slot; + let xscale = draw.xscale; + let base = draw.base; + let text_col = draw.text_color; + let ticks = axis_ticks; if let Some(sc) = xscale { for v in ticks(sc) { let pos = plot.x0 + (v - sc.0) / positive_span(sc.1 - sc.0) * plot.width(); @@ -599,11 +754,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &fmt_num(v), Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } } @@ -618,11 +769,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, name, Rect::new(area.x0, mid - base, plot.x0 - base * 0.4, mid + base), - base, - Align::Right, - Anchor::Middle, - text_col, - false, + LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } else { let text = if wraps { split_label(name) } else { name.clone() }; @@ -632,23 +779,181 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &text, Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }), - base, - Align::Center, - Anchor::Top, - text_col, - false, + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Top, col: text_col, bold: false }, ); } } - if let Some(b) = boxes { - crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); - return; +} + +struct AxisTitleDrawing<'a> { + chart: &'a Chart, + transform: Affine, + base: f64, + text_color: Rgba, + horiz: bool, +} + +fn draw_axis_titles(ctx: &mut RenderContext, rctx: &Ctx, draw: &AxisTitleDrawing<'_>, mut area: Rect) -> Rect { + let c = draw.chart; + let m = draw.transform; + let base = draw.base; + let text_col = draw.text_color; + let horiz = draw.horiz; + // Axis titles: the left one turned to read upwards, the bottom one centred under the axis. + // On a horizontal bar chart the category axis is the left one. + let (left_title, bottom_title) = + if horiz { (c.axis_titles.0.as_deref(), c.axis_titles.1.as_deref()) } else { (c.axis_titles.1.as_deref(), c.axis_titles.0.as_deref()) }; + let band_h = base * 1.6; + if let Some(t) = bottom_title.filter(|t| !t.is_empty()) { + let r = Rect::new(area.x0, area.y1 - band_h, area.x1, area.y1); + label(ctx, rctx, m, t, r, LabelStyle { size: base, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: true }); + area.y1 -= band_h + base * 0.3; } - let zero = match log { - Some((b, _)) => to_v(b.powf(min)), - None => to_v(clamp_soft(0.0, min, max)), - }; - // Error bars, drawn over every series: (position across the value axis, from, to, cap length, cap). + if let Some(t) = left_title.filter(|t| !t.is_empty()) { + let (cx, cy) = (area.x0 + band_h / 2.0, (area.y0 + area.y1) / 2.0); + let turned = m * Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2); + let half = area.height() / 2.0; + label( + ctx, + rctx, + turned, + t, + Rect::new(-half, -band_h / 2.0, half, band_h / 2.0), + LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: text_col, bold: true }, + ); + area.x0 += band_h + base * 0.3; + } + area +} + +struct BarDrawing<'a, 'ctx> { + rctx: &'a Ctx<'ctx>, + chart: &'a Chart, + transform: Affine, + plot: Rect, + band: f64, + horiz: bool, + ncat: usize, + bar_kind: Option, + waterfall: bool, + slot: &'a dyn Fn(usize) -> f64, + to_v: &'a dyn Fn(f64) -> f64, + to_axis: &'a dyn Fn(bool, f64) -> f64, + base: f64, + text_color: Rgba, +} + +struct LineDrawing<'a, 'ctx> { + chart: &'a Chart, + rctx: &'a Ctx<'ctx>, + transform: Affine, + plot: Rect, + category_count: usize, + band: f64, + slot: &'a dyn Fn(usize) -> f64, + to_x: &'a dyn Fn(f64) -> Option, + to_axis: &'a dyn Fn(bool, f64) -> f64, + zero: f64, + base: f64, + error_marks: &'a mut Vec<(f64, f64, f64, f64, bool)>, +} + +fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) { + let c = draw.chart; + let rctx = draw.rctx; + let m = draw.transform; + let plot = draw.plot; + let ncat = draw.category_count; + let band = draw.band; + let slot = draw.slot; + let to_x = draw.to_x; + let to_axis = draw.to_axis; + let zero = draw.zero; + let base = draw.base; + let err_marks = &mut draw.error_marks; + let mut acc = vec![0.0; ncat]; + for (si, s) in c.series.iter().enumerate() { + let kind = c.series_kind(s); + if kind.is_bar_like() || kind == ChartType::Stock { + continue; + } + let sec = c.on_secondary(s); + let stacked = kind.stacked(); + let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); + let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; + let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); + 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 kind { + ChartType::Scatter | ChartType::Bubble => { + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), + (None, Some(_)) => continue, + } + } + _ => plot.x0 + band * (slot(i) + 0.5), + }; + if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { + err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); + } + pts.push(Point::new(x, to_axis(sec, v))); + } + if pts.is_empty() { + continue; + } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; + ctx.set_transform(m); + if area_kind { + let mut a = p.clone(); + 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 kind != ChartType::Scatter { + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&p); + } + if matches!(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)); + } + } + } +} + +fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f64, f64, f64, bool)> { + let c = draw.chart; + let rctx = draw.rctx; + let m = draw.transform; + let plot = draw.plot; + let band = draw.band; + let horiz = draw.horiz; + let ncat = draw.ncat; + let bar_kind = draw.bar_kind; + let waterfall = draw.waterfall; + let slot = draw.slot; + let to_v = draw.to_v; + let to_axis = draw.to_axis; + let base = draw.base; + let text_col = draw.text_color; let mut err_marks: Vec<(f64, f64, f64, f64, bool)> = vec![]; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = @@ -748,124 +1053,77 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, m, &t, lr, - base * 0.9, - if horiz { Align::Left } else { Align::Center }, - if horiz { Anchor::Middle } else { Anchor::Bottom }, - text_col, - false, + LabelStyle { + size: base * 0.9, + align: if horiz { Align::Left } else { Align::Center }, + anchor: if horiz { Anchor::Middle } else { Anchor::Bottom }, + col: text_col, + bold: false, + }, ); } } } } - // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. - if c.kind == ChartType::Stock { - for i in 0..ncat { - crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); - } + err_marks +} + +fn draw_pie(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let 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; } - // Lines, areas, scatter: the series that aren't bars. - let mut acc = vec![0.0; ncat]; - for (si, s) in c.series.iter().enumerate() { - let kind = c.series_kind(s); - if kind.is_bar_like() || kind == ChartType::Stock { - continue; - } - let sec = c.on_secondary(s); - let stacked = kind.stacked(); - let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar); - let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) }; - let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values)); - 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 kind { - ChartType::Scatter | ChartType::Bubble => { - match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { - (Some(x), _) => x, - // No x values at all: the points are numbered 1, 2, 3... - (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)), - (None, Some(_)) => continue, - } - } - _ => plot.x0 + band * (slot(i) + 0.5), - }; - if let Some((lo, hi)) = s.error_bars.as_ref().and_then(|e| series_stats.as_ref().and_then(|st| e.range_in(st, i, v))) { - err_marks.push((x, to_axis(sec, lo), to_axis(sec, hi), base * 0.4, s.error_bars.as_ref().is_some_and(|e| e.end_cap))); - } - pts.push(Point::new(x, to_axis(sec, v))); - } - if pts.is_empty() { - continue; - } - let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; - ctx.set_transform(m); - if area_kind { - let mut a = p.clone(); - 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 kind != ChartType::Scatter { - let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); - ctx.set_paint(color(col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round)); - ctx.stroke_path(&p); - } - if matches!(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)); - } - } - } - // A category position (0-based, fractional) as a slot, the way `slot` places the bars. - let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; - draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); - if !err_marks.is_empty() { + let 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(); - for &(mid, a, b, cap, end_cap) in &err_marks { - // A horizontal bar chart's error bars run across, its value axis being horizontal. - let pt = |along: f64, across: f64| if horiz { Point::new(along, across) } else { Point::new(across, along) }; - p.move_to(pt(a, mid)); - p.line_to(pt(b, mid)); - if end_cap { - for e in [a, b] { - p.move_to(pt(e, mid - cap)); - p.line_to(pt(e, mid + cap)); - } - } - } + 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); - ctx.set_paint(color(text_col, 1.0)); - ctx.set_stroke(kurbo::Stroke::new(0.75)); + 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.series_data_labels(s) { + 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, + &c.data_label(s, *v), + Rect::new(lp.x - 30.0, lp.y - 10.0, lp.x + 30.0, lp.y + 10.0), + LabelStyle { + size: base, + align: Align::Center, + anchor: Anchor::Middle, + // On the slices themselves: white, unless the chart's text was given a colour. + col: if c.text_color.is_some() { text_col } else { Rgba::WHITE }, + bold: false, + }, + ); + } + a0 += sweep; } - // 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)); + 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)); } - 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); } /// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than @@ -943,36 +1201,35 @@ fn forecast_pad(c: &Chart) -> (f64, f64) { const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; -fn map_xy( +struct XYMapper<'a> { plot: Rect, band: f64, - slot: &impl Fn(f64) -> f64, + slot: &'a dyn Fn(f64) -> f64, horiz: bool, scatter: bool, - sx0: f64, - sx1: f64, - sec: bool, - x: f64, - y: f64, - to_axis: &impl Fn(bool, f64) -> f64, -) -> Option { + scatter_x: (f64, f64), + secondary: bool, + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn map_xy(mapper: &XYMapper<'_>, x: f64, y: f64) -> Option { if !x.is_finite() || !y.is_finite() { return None; } - let value = to_axis(sec, y); + let value = (mapper.to_axis)(mapper.secondary, y); if !value.is_finite() { return None; } - let along = if scatter && sx1 > sx0 { - plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + let along = if mapper.scatter && mapper.scatter_x.1 > mapper.scatter_x.0 { + mapper.plot.x0 + (x - mapper.scatter_x.0) / (mapper.scatter_x.1 - mapper.scatter_x.0) * mapper.plot.width() } else { - let origin = if horiz { plot.y0 } else { plot.x0 }; - origin + band * (slot(x - 1.0) + 0.5) + let origin = if mapper.horiz { mapper.plot.y0 } else { mapper.plot.x0 }; + origin + mapper.band * ((mapper.slot)(x - 1.0) + 0.5) }; if !along.is_finite() { return None; } - Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) + Some(if mapper.horiz && !mapper.scatter { Point::new(value, along) } else { Point::new(along, value) }) } /// The part of segment `a`–`b` that lies inside `r`. @@ -1006,21 +1263,20 @@ fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) } -fn draw_trendlines( - ctx: &mut RenderContext, - rctx: &Ctx, - c: &Chart, - m: Affine, +struct TrendlineLayout<'a> { plot: Rect, band: f64, - slot: &impl Fn(f64) -> f64, + slot: &'a dyn Fn(f64) -> f64, xscale: Option<(f64, f64, f64)>, ncat: usize, horiz: bool, base: f64, text_col: Rgba, - to_axis: &impl Fn(bool, f64) -> f64, -) { + to_axis: &'a dyn Fn(bool, f64) -> f64, +} + +fn draw_trendlines(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, layout: TrendlineLayout<'_>) { + let TrendlineLayout { plot, band, slot, xscale, ncat, horiz, base, text_col, to_axis } = layout; let mut label_n = 0usize; for s in &c.series { if s.trendlines.is_empty() { @@ -1057,6 +1313,7 @@ fn draw_trendlines( } else { (0.0, 0.0) }; + let mapper = XYMapper { plot, band, slot, horiz, scatter, scatter_x: (sx0, sx1), secondary: sec, to_axis }; for (ti, t) in s.trendlines.iter().take(8).enumerate() { let Some(fit) = t.fit(&xs, &ys) else { continue }; let (x0, x1) = fit.span(t.forward, t.backward); @@ -1066,10 +1323,7 @@ fn draw_trendlines( let mut ink_pts = Vec::new(); let mut any = false; for w in samples.windows(2) { - let (Some(a), Some(b)) = ( - map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), - map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), - ) else { + let (Some(a), Some(b)) = (map_xy(&mapper, w[0].0, w[0].1), map_xy(&mapper, w[1].0, w[1].1)) else { prev = None; continue; }; @@ -1117,11 +1371,7 @@ fn draw_trendlines( m, &caption, Rect::new(x, y, x + box_w, y + box_h), - (base * 0.9).clamp(8.0, 13.0), - Align::Left, - Anchor::Top, - text_col, - false, + LabelStyle { size: (base * 0.9).clamp(8.0, 13.0), align: Align::Left, anchor: Anchor::Top, col: text_col, bold: false }, ); label_n += 1; } diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index 55350d5f3..9a9a66b8e 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -62,9 +62,23 @@ fn funnel( ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); ctx.fill_rect(&r); if let Some(name) = c.categories.get(i) { - label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + name, + Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), + crate::chart::LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } - label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &value_label(*v), + Rect::new(cx - 60.0, y, cx + 60.0, y + band), + crate::chart::LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, + ); } } @@ -118,7 +132,20 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: } let inner = t.inset(-base * 0.25); if inner.width() > base && inner.height() > base * 0.8 { - label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { + size: base * 0.9, + align: Align::Center, + anchor: Anchor::Middle, + col: if dark { Rgba::WHITE } else { text_col }, + bold: false, + }, + ); } } } @@ -136,9 +163,23 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1)); } let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2); - label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + &c.data_label(s, min), + ly, + crate::chart::LabelStyle { size: base, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, + ); let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2); - label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + &c.data_label(s, max), + ry, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1); label( @@ -147,11 +188,13 @@ fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: m, "Map preview unavailable: regions shown as tiles", note, - base * 0.85, - Align::Center, - Anchor::Middle, - mix(text_col, Rgba::WHITE, 0.4), - false, + crate::chart::LabelStyle { + size: base * 0.85, + align: Align::Center, + anchor: Anchor::Middle, + col: mix(text_col, Rgba::WHITE, 0.4), + bold: false, + }, ); } @@ -248,7 +291,14 @@ fn treemap( } let inner = t.inset(-base * 0.3); if inner.width() > base && inner.height() > base { - label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Top, col: Rgba::WHITE, bold: false }, + ); } } } @@ -289,11 +339,7 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, name, Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), - base, - Align::Center, - Anchor::Middle, - Rgba::WHITE, - false, + crate::chart::LabelStyle { size: base, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, ); } a0 = a1; @@ -344,7 +390,14 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: } let inner = t.inset(-base * 0.3); if inner.width() > base && inner.height() > base { - label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false); + label( + ctx, + rctx, + m, + &text, + inner, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Left, anchor: Anchor::Top, col: Rgba::WHITE, bold: false }, + ); } continue; } @@ -355,7 +408,14 @@ fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: ctx.fill_rect(&t); if t.width() > base * 2.0 && banner > base * 0.8 { let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner); - label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true); + label( + ctx, + rctx, + m, + &n.name, + head, + crate::chart::LabelStyle { size: base, align: Align::Left, anchor: Anchor::Middle, col: Rgba::WHITE, bold: true }, + ); } let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0); let k = lighten(fill, 0.12 * (depth + 1) as f64); @@ -437,11 +497,7 @@ fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area m, &n.name, Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base), - base * 0.9, - Align::Center, - Anchor::Middle, - Rgba::WHITE, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Center, anchor: Anchor::Middle, col: Rgba::WHITE, bold: false }, ); } if !n.children.is_empty() { diff --git a/crates/render/src/geometry.rs b/crates/render/src/geometry.rs new file mode 100644 index 000000000..12b227961 --- /dev/null +++ b/crates/render/src/geometry.rs @@ -0,0 +1,201 @@ +use super::*; + +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) +} + +pub(crate) 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)); + // PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path + // inside another cuts a hole whichever way it winds, as in icons and outlined text. + out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true }); + } + 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 transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine +/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts. +pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option { + if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { + return None; + } + let (e, _) = resolve::effects(rctx, s); + e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h)) +} + +/// 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) +} + +/// Whether a shape's border is drawn outside its outline: a picture's is, unless the picture is +/// drawn in 3-D (a bevel seen through a 3-D scene), where PowerPoint centres it on the edge as on +/// any shape (observed: an 18 pt border covers 9 pt of the picture then). +pub(crate) fn border_outside(is_pic: bool, effects: Option<&Effects>) -> bool { + is_pic && !effects.is_some_and(|e| e.bevel.is_some() && e.raw3d.as_deref().is_some_and(|r| r.contains("scene3d"))) +} + +/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside +/// the outline is the border, with dashes kept their length. `None` for compound lines, whose +/// bands would not survive the doubling (they stay centred on the outline). +pub(crate) fn outside_line(line: &Line, lw: f64) -> Option { + if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) { + return None; + } + let dash = line.dash.as_ref().map(|d| { + let p = d.pattern(); + deckcraft_model::style::Dash::Custom { + pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(), + } + }); + Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() }) +} + +/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity, +/// and the space it is stroked in (see `Renderer::line_space`). +pub(crate) struct Caster<'a> { + pub(crate) line: std::borrow::Cow<'a, Line>, + pub(crate) ph: Option, + pub(crate) alpha: f64, + pub(crate) base: Affine, + pub(crate) rel: Affine, +} + +/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a +/// gradient's mean stop alpha, otherwise opaque. +pub(crate) fn fill_opacity(rctx: &Ctx, fill: &Option, ph: Option) -> Option { + match fill { + None | Some(Fill::None) => None, + Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0), + Some(Fill::Gradient(g)) if !g.stops.is_empty() => { + Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::() / g.stops.len() as f64) + } + Some(_) => Some(1.0), + } +} + +/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point +/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the +/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow +/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its +/// middle. +pub(crate) fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine { + let (ax, ay) = match sh.align.as_str() { + "tl" => (0.0, 0.0), + "t" => (0.5, 0.0), + "tr" => (1.0, 0.0), + "l" => (0.0, 0.5), + "r" => (1.0, 0.5), + "bl" => (0.0, 1.0), + "b" => (0.5, 1.0), + "br" => (1.0, 1.0), + _ => (0.5, 0.5), + }; + let a = Vec2::new(w * ax, h * ay); + let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan()); + let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]); + if !k.is_finite() { + return Affine::IDENTITY; + } + Affine::translate(a) * k * Affine::translate(-a) +} + +pub(crate) fn pre_translate(m: Affine, d: Vec2) -> Affine { + Affine::translate(d) * m +} + +pub(crate) fn scale_of(m: Affine) -> f64 { + m.determinant().abs().sqrt() +} + +pub(crate) fn blur(sigma: f64) -> Filter { + Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1e7c3dca8..5fd011411 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,2445 +60,33 @@ pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; pub use transition::{SlideParts, composite_transition, render_transition}; -/// 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 - } - /// One pixel as straight (unpremultiplied) RGBA; `None` outside the image. - pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> { - if x >= self.width || y >= self.height { - return None; - } - let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?; - let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?; - if a == 0 || a == 255 { - return Some([r, g, b, a]); - } - let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8; - Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a]) - } - pub fn to_png(&self) -> Vec { - let mut px = self.to_straight(); - px.resize(self.width as usize * self.height as usize * 4, 0); - 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 - } - - /// PNG of this image whose `pHYs` chunk says `dpi` dots per inch. - /// - /// Slide space is points, 72 per inch, so a render at `scale` pixels per point is - /// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a - /// Retina (2×) export opens at twice the slide's size. - pub fn to_png_at(&self, dpi: f64) -> Vec { - images::stamp_png_dpi(&self.to_png(), dpi) - } - - pub fn to_jpeg(&self, quality: u8) -> Vec { - self.to_jpeg_at(quality, None) - } - - /// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range. - pub fn to_jpeg_at(&self, quality: u8, dpi: Option) -> Vec { - let straight = self.to_straight(); - let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); - let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; - let mut buf = Vec::new(); - let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); - if let Some(d) = dpi.and_then(dpi_u16) { - enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d)); - } - if let Err(e) = img.write_with_encoder(enc) { - log::error!("JPEG encode: {e}"); - return Vec::new(); - } - 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] - } -} - -/// Dots per inch for a slide drawn at `pixels_per_point`. -/// -/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi: -/// the same pixels, at the slide's real size, on a Retina display. -pub fn dpi_for_scale(pixels_per_point: f64) -> f64 { - if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { - return 72.0; - } - (pixels_per_point * 72.0).clamp(1.0, 10_000.0) -} - -fn dpi_u16(dpi: f64) -> Option { - if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) { - return None; - } - Some(dpi.round() as u16) -} - -/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch. -pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> { - images::dpi(bytes) -} - -/// Animation state of one shape during a slide show or preview. -#[derive(Clone, Debug)] -pub struct ShapeState { - 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, - /// Per-paragraph states of by-paragraph text builds (paragraph index, state). - pub paras: Vec<(usize, ParaState)>, -} - -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, paras: vec![] } - } -} - -/// Animation state of one paragraph, relative to its shape. -#[derive(Clone, Copy, Debug)] -pub struct ParaState { - pub visible: bool, - pub opacity: f64, - /// Translation in shape-local points. - pub offset: Vec2, -} - -impl Default for ParaState { - fn default() -> Self { - ParaState { visible: true, opacity: 1.0, offset: Vec2::ZERO } - } -} - -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, - /// Text being edited: replace its prompt with gray markers on empty list paragraphs. - /// Ignored outside editor rendering. - pub editing_text: Option, - /// Animated GIF pictures and how far into their play they are (seconds); others show frame 1. - pub gif_times: &'a [(ShapeId, f64)], - /// 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, - /// Show this text in every date field instead of the date. - pub date_text: Option, - /// The local date and time date fields show (`None`: the system clock). - pub now: Option, - /// Leave the slide background out (neither the white underlay nor the background fill), so - /// only what is on the slide is drawn over transparency: PNG export with a transparent - /// background. Shapes filled with the background (`useBgFill`) still show it. - pub transparent_background: bool, -} - -impl Default for RenderOpts<'_> { - fn default() -> Self { - RenderOpts { - scale: 1.0, - edit: false, - show_hidden: false, - state: None, - skip: &[], - skip_text: None, - editing_text: None, - gif_times: &[], - threads: 0, - offset: (0.0, 0.0), - size: None, - clear: None, - date_text: None, - now: None, - transparent_background: false, - } - } -} - -struct SlideFields { - num: u32, - /// Text for every date field instead of the date ([`RenderOpts::date_text`]). - date: Option, - footer: String, - /// The local date and time date fields show; `None`: they keep their saved text. - now: Option, -} - -impl SlideFields { - fn new(num: u32, footer: String, opts: &RenderOpts) -> Self { - SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) } - } -} - -impl Fields for SlideFields { - fn field(&self, kind: &str) -> Option { - self.field_in(kind, None) - } - - fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { - if kind == "slidenum" { - return Some(self.num.to_string()); - } - if datetime::is_date_field(kind) { - // An automatic date: today's, in the format of its type. (A fixed date is plain text - // in the slide's placeholder, not a field.) - return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?)); - } - if kind == "footer" { - return Some(self.footer.clone()); - } - None - } -} - -pub struct Renderer { - resources: Resources, - /// Outlines morphing in the frame being drawn by `blend`. - paths: Vec, - /// The background of the slide (layout, master) being drawn, its placeholder colour and the - /// slide's view transform: shapes filled with the background (`useBgFill`) show it through. - bg: Option<(Fill, Option, Affine)>, - /// What the frame being drawn has painted so far, in output pixels: the editor learns from it - /// how far a slide's drawing (text spilling out of its box too) reaches off the slide. - drawn: Option, - /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw - /// under it combined with the group's child-coordinate mapping, which must not scale lines. - view: Affine, - /// The fill of the group being drawn, with the transform and box it is laid out in: children - /// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group. - group_fill: Option<(Fill, Affine, Rect)>, - /// Their aligned outlines, kept from frame to frame. - morphs: morph_path::Cache, - /// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group. - in_shadow_pass: bool, - /// Cumulative shape traversal counter to prevent nested group amplification DoS. - render_work: usize, - /// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget - /// of the normal drawing that follows. - shadow_work: usize, -} - -impl Default for Renderer { - fn default() -> Self { - Renderer { - resources: Resources::new(), - paths: Vec::new(), - bg: None, - drawn: None, - view: Affine::IDENTITY, - group_fill: None, - morphs: morph_path::Cache::default(), - in_shadow_pass: false, - render_work: 0, - shadow_work: 0, - } - } -} - -static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - -fn note_panic() { - PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); -} - -/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. -pub fn panics_caught() -> u64 { - PANICS.load(std::sync::atomic::Ordering::Relaxed) -} - -thread_local! { - static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); -} - -/// Does anything on this slide (or its layout/master graphics) use blur-based effects? -pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { - let Some(rctx) = Ctx::for_slide(pres, slide) else { return false }; - let mut any = false; - let mut check = |shapes: &[Shape]| { - deckcraft_model::walk(shapes, &mut |s, _| { - if any { - return; - } - let (e, _) = resolve::effects(&rctx, s); - if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) { - any = true; - } - }); - }; - check(&slide.shapes); - if let Some(l) = rctx.layout { - check(&l.shapes); - } - check(&rctx.master.shapes); - any -} - -/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's -/// text styles it inherits)? Such text is blurred, which needs filters. -fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { - use deckcraft_model::text::{ListStyle, RunProps}; - let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0); - let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run)); - let Some(t) = &s.text else { return false }; - t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props)) - || styled(&t.list_style) - || styled(&rctx.master.title_style) - || styled(&rctx.master.body_style) - || styled(&rctx.master.other_style) -} - -/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a -/// blank image (logged), never a crash. -pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { - render_slide_drawn(pres, index, opts).0 -} - -/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the -/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output -/// included). The editor grows its pasteboard to it ([`pasteboard_region`]). -pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) { - let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height); - let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) }; - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RENDERER.with(|r| { - let mut r = r.borrow_mut(); - r.drawn = None; - let img = r.slide(pres, slide, index, opts); - (img, r.drawn.take()) - }) - })) { - Ok((img, drawn)) => { - let k = opts.scale; - let to_slide = - |d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k); - let extent = match drawn { - Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)), - _ => page, - }; - (img, extent) - } - Err(_) => { - log::error!("rendering slide {index} panicked; showing a blank slide"); - note_panic(); - RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); - let (w, h) = output_size(pres, opts); - (Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page) - } - } -} - -/// Which part of a slide [`render_slide_part`] draws. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SlidePart { - /// The background fill with the master and layout graphics. - Backdrop, - /// The slide's own shapes over transparency. - Content, -} - -/// Render one part of slide `index` (transitions that move the contents over the backgrounds). -/// A panic inside the renderer becomes a transparent image (logged), never a crash. -pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { - let Some(slide) = pres.slides.get(index) else { return Image::default() }; - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide_part(pres, slide, index, part, opts)))) { - Ok(img) => img, - Err(_) => { - log::error!("rendering part of slide {index} panicked; showing nothing"); - note_panic(); - RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); - let (w, h) = output_size(pres, opts); - Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] } - } - } -} - -/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the -/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by -/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`: -/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or -/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw -/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies -/// to `shapes` only. -pub fn render_blend( - pres: &Presentation, - old: usize, - new: usize, - mix: f64, - shapes: &[(Shape, bool)], - text: &[TextMorph], - paths: &[PathMorph], - opts: &RenderOpts, -) -> Image { - let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() }; - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts)) - })) { - Ok(img) => img, - Err(_) => { - log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); - note_panic(); - RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); - render_slide(pres, new, opts) - } - } -} - -/// Render a layout (Slide Master view thumbnail and canvas). -pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) { - Ok(img) => img, - Err(_) => { - log::error!("rendering a layout panicked; showing a blank image"); - note_panic(); - RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); - Image::default() - } - } -} - -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)); - // PowerPoint fills each custom path's sub-paths alternately (even-odd): a sub-path - // inside another cuts a hole whichever way it winds, as in icons and outlined text. - out.push(SubPath { path: bp, fill: p.fill, stroke: p.stroke, even_odd: true }); - } - 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 transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine -/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts. -pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option { - if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { - return None; - } - let (e, _) = resolve::effects(rctx, s); - e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h)) -} - -/// 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 canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { - 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") || filters { 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); - (ctx, w, h, view) - } - - /// Note that `r` (local space, placed by `m`) was drawn. - fn track(&mut self, m: Affine, r: Rect) { - let b = m.transform_rect_bbox(r); - if b.is_finite() { - self.drawn = Some(self.drawn.map_or(b, |d| d.union(b))); - } - } - - fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields { - SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts) - } - - /// Background plus the master and layout graphics the slide shows. - fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { - self.background(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(f.pres, rctx.master); - for s in &rctx.master.shapes { - if s.ph.is_none() { - self.shape(ctx, f, &mctx, s, view, 0); - } - } - } - if show_layout && let Some(l) = rctx.layout { - let lctx = Ctx::for_layout(f.pres, rctx.master, l); - for s in &l.shapes { - if s.ph.is_none() { - self.shape(ctx, f, &lctx, s, view, 0); - } - } - } - } - - fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); - let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; - if let Some(rctx) = Ctx::for_slide(pres, slide) { - self.backdrop(&mut ctx, &f, &rctx, slide, view); - for s in &slide.shapes { - self.shape(&mut ctx, &f, &rctx, s, view, 0); - } - } - self.finish(ctx, w, h) - } - - fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); - let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; - if let Some(rctx) = Ctx::for_slide(pres, slide) { - match part { - SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view), - SlidePart::Content => { - // Shapes filled with the background (`useBgFill`) still show it. - let (fill, ph) = resolve::background(&rctx, Some(slide)); - self.bg = Some((fill, ph, view)); - for s in &slide.shapes { - self.shape(&mut ctx, &f, &rctx, s, view, 0); - } - } - } - } - self.finish(ctx, w, h) - } - - #[allow(clippy::too_many_arguments)] - fn blend( - &mut self, - pres: &Presentation, - old: (&Slide, usize), - new: (&Slide, usize), - mix: f64, - shapes: &[(Shape, bool)], - text: &[TextMorph], - paths: &[PathMorph], - opts: &RenderOpts, - ) -> Image { - let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); - let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; - // Backdrop graphics never take shape states: their ids may clash with the frame's shapes. - let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts }; - if mix < 1.0 { - let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) }; - self.backdrop(&mut ctx, &f, &co, old.0, view); - } - if mix > 0.0 { - let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) }; - let layered = mix < 1.0; - if layered { - ctx.push_layer(None, None, Some(mix as f32), None, None); - } - self.backdrop(&mut ctx, &f, &cn, new.0, view); - if layered { - ctx.pop_layer(); - } - } - let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) }; - let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) }; - let mut drawn: Vec = Vec::new(); - // Set only now: the backdrop's ids may clash with the frame's. - self.paths = paths.to_vec(); - for (s, from_old) in shapes { - let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) }; - let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else { - self.shape(&mut ctx, f, c, s, view, 0); - continue; - }; - self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0); - drawn.push(s.id); - if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) { - continue; - } - let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id); - if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) { - let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b)); - morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view); - } - } - self.paths.clear(); - self.morphs.sweep(); - self.finish(ctx, w, h) - } - - fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { - self.render_work = 0; - self.shadow_work = 0; - self.in_shadow_pass = false; - 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::new(1, String::new(), opts) }; - 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); - if f.opts.transparent_background { - self.bg = Some((fill, ph, view)); - return; - } - 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); - // PowerPoint lays every background over white: a picture stretched into part of the - // slide, or a see-through colour, leaves white around and under it. - ctx.set_paint(color(Rgba::WHITE, 1.0)); - ctx.fill_path(&path); - paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); - self.bg = Some((fill, ph, view)); - } - - fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { - if depth == 0 { - let outer = std::mem::replace(&mut self.view, parent); - self.shape_at(ctx, f, rctx, s, parent, depth); - self.view = outer; - } else { - self.shape_at(ctx, f, rctx, s, parent, depth); - } - } - - /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the - /// one that takes the shape's outline into it. Inside a group they are the slide-level view and - /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but - /// never the line width; outside a group, `m` itself and no change to the outline. - fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) { - let view = self.view; - if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } - } - - /// Shape-space units per point: a group's child coordinates can scale a shape's box (a legacy - /// 1360-unit child space makes one unit hundreds of EMUs), but 3-D sizes kept in points - /// (contour width, extrusion depth, bevel) stay points, as PowerPoint draws them. 1 outside a - /// group. - fn per_point(&self, m: Affine, depth: usize) -> f64 { - let k = scale_of(self.line_space(m, depth).1); - if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } - } - - /// The size and transform a shape's text is set in: inside a group whose child coordinates - /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so - /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. - fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) { - let (_, rel) = self.line_space(m, depth); - let c = rel.as_coeffs(); - let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3])); - let ok = |k: f64| k.is_finite() && k > 1e-9; - if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) { - return (w, h, m); - } - let (tw, th) = (w * sx, h * sy); - if !tw.is_finite() || !th.is_finite() { - return (w, h, m); - } - (tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy)) - } - - /// A shape's geometry in its own `w` × `h` space, built at its size on the slide: inside a - /// group whose child coordinates scale the shape unevenly, the preset's rounded corners and - /// other size-derived features stay as they would be on a plain shape of that size (#1083). - fn scaled_geometry(&self, s: &Shape, m: Affine, depth: usize, w: f64, h: f64) -> Geometry { - let (tw, th, _) = self.text_space(m, depth, w, h); - if (tw, th) == (w, h) { - return shape_geometry(s, w, h); - } - let mut geo = shape_geometry(s, tw, th); - let k = |a: f64, b: f64| if b.abs() > 1e-12 { a / b } else { 1.0 }; - let back = Affine::scale_non_uniform(k(w, tw), k(h, th)); - for p in &mut geo.paths { - p.path.apply_affine(back); - } - geo.text_rect = back.transform_rect_bbox(geo.text_rect); - for p in &mut geo.sites { - *p = back * *p; - } - geo - } - - fn shape_at(&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; - } - let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work }; - *work = work.saturating_add(1); - if *work > 100_000 { - 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; - } - if let Some(a) = camera_turn(rctx, s, x.w, x.h) { - m *= a; - } - 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 * deckcraft_geom::group_child_affine(&x, ch); - // A group's own fill (a group filled with its parent's passes that on). - let own = match &s.fill { - Some(Fill::Group) | None => self.group_fill.clone(), - Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), - }; - let outer = std::mem::replace(&mut self.group_fill, own); - // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the - // children drawn once more under the shadow's offset, scale and skew, recoloured to - // the shadow colour and blurred, below the group. - if !self.in_shadow_pass - && let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) - { - let c = rctx.color(&sh.color, None); - let k = scale_of(m); - let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - let local = shadow_local(sh, x.w, x.h); - let (own, pre) = - if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; - let det = m.determinant(); - if det.is_finite() && det.abs() > 1e-12 { - let shift = pre * m * own * m.inverse(); - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); - self.in_shadow_pass = true; - for ch in children { - self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); - } - self.in_shadow_pass = false; - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); - ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); - ctx.pop_layer(); - ctx.pop_layer(); - } - } - for c in children { - self.shape(ctx, f, rctx, c, gm, depth + 1); - } - self.group_fill = outer; - } - ShapeKind::Table(t) => { - // Rows grow with their text: what the cells cover, borders included. - let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id)); - self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0)); - } - ShapeKind::Chart(c) => { - self.track(m, Rect::new(0.0, 0.0, x.w, x.h)); - chart::draw(ctx, rctx, c, m, x.w, x.h) - } - ShapeKind::Ink { strokes } => { - for sk in strokes { - let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); - let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; - for (px, py, pr) in &sk.points { - let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); - self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); - } - let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); - if pts.len() < 2 { - continue; - } - let (base, rel) = self.line_space(parent, depth); - ctx.set_transform(base); - ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); - // Uniform-width and highlighter strokes are one path: segments drawn one by one - // overlap at the joins, which darkens see-through highlighter ink in beads. - if legacy || sk.highlighter { - let mut bp = BezPath::new(); - for (i, (px, py, _)) in pts.iter().enumerate() { - if i == 0 { - bp.move_to((*px, *py)); - } else { - bp.line_to((*px, *py)); - } - } - bp.apply_affine(rel); - ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); - continue; - } - for pair in pts.windows(2) { - let [p0, p1] = pair else { continue }; - let mut bp = BezPath::new(); - bp.move_to((p0.0, p0.1)); - bp.line_to((p1.0, p1.1)); - bp.apply_affine(rel); - let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy); - let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy); - ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); - ctx.stroke_path(&bp); - } - } - } - _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), - } - if layered { - ctx.pop_layer(); - } - } - - /// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per - /// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill` - /// the group's fill. - #[allow(clippy::too_many_arguments)] - fn fill_geometry( - &self, - ctx: &mut RenderContext, - f: &Frame, - rctx: &Ctx, - fl: &Fill, - fill_ph: Option, - geo: &Geometry, - (w, h): (f64, f64), - m: Affine, - fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, - ) { - for (i, sp) in geo.paths.iter().enumerate() { - if sp.fill == FillMode::None || fade(i).fill <= 0.0 { - continue; - } - ctx.set_transform(m); - ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); - match (fl, &self.bg) { - // `useBgFill`: the slide's background, laid out on the slide (not the - // shape), shows through the shape. - (Fill::Background, Some((bg, ph, view))) => { - let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); - let path = (view.inverse() * m) * sp.path.clone(); - paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres); - } - // `grpFill`: the group's fill, laid out over the whole group. - (Fill::Group, _) => { - if let Some((gf, gm, gbox)) = &self.group_fill - && gm.determinant().abs() > 1e-12 - { - let path = (gm.inverse() * m) * sp.path.clone(); - paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); - } - } - _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), - } - ctx.set_fill_rule(peniko::Fill::NonZero); - } - } - - #[allow(clippy::too_many_arguments)] - fn geometry_shape( - &mut self, - ctx: &mut RenderContext, - f: &Frame, - rctx: &Ctx, - s: &Shape, - m: Affine, - w: f64, - h: f64, - st: &ShapeState, - depth: usize, - ) { - let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { - Some(pm) => self.morphs.geometry(pm, s, w, h), - None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), - }; - let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); - 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 reach = extent::spill(Some(&line), effects.as_ref(), w, h); - self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach)); - 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(deckcraft_model::ColorRef::rgb(t))) } else { fill }; - // Effects below the shape. - if let Some(e) = &effects { - // A picture (or picture fill) casts its shadow from its pixels, not its frame. - let content = match (&s.kind, &fill) { - (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), - _ => None, - }; - // What casts the shadow: the fill as see-through as it is, and the outline. - let fill_alpha = fill_opacity(rctx, &fill, fill_ph); - let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { - let a = match lf { - Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, - _ => 1.0, - }; - let (base, rel) = self.line_space(m, depth); - let lw = line.width.unwrap_or(0.75).max(0.0); - let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(&line, lw) } else { None }; - Caster { line: outside.map_or(std::borrow::Cow::Borrowed(&line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } - }); - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, fill_alpha, outline, content.as_ref(), 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; - } - // Shape-space units per point for the 3-D sizes, measured without the camera's turn (which - // `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. - let pt = { - let turn = effects - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, w, h)) - .filter(|t| t.determinant().abs() > 1e-9); - self.per_point(turn.map_or(m, |t| m * t.inverse()), depth) - }; - // A 3-D extrusion under a turned camera: the sides, swept behind the face. - if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) - && let Some(ex) = extrude::from_raw(raw) - && let Some(cam) = camera::from_raw(raw) - && let Some(turn) = camera::affine(&cam, w, h) - && turn.determinant().abs() > 1e-9 - { - let face = match &fill { - Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), - _ => None, - }; - let pre = m * turn.inverse(); - let lit = light::Lighting::from_raw(raw); - let d = ex.depth * pt; - extrude::draw(ctx, &outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); - } - // A 3-D contour: a band around the outline, behind the face (and in front of any sides). - if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) - && let Some(c) = extrude::contour_from_raw(raw) - { - let face = match &fill { - Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)), - _ => None, - }; - let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); - extrude::draw_contour(ctx, &outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); - } - // Fill. - match &s.kind { - ShapeKind::Picture { fill: pf } => { - // The picture's own fill shows behind its transparent pixels, as in PowerPoint (an - // emphasis tint recolours it, as it does any shape's fill). - if s.fill.as_ref().is_some_and(|f| !f.is_none()) - && let Some(fl) = &fill - { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); - } - ctx.set_transform(m); - let bounds = Rect::new(0.0, 0.0, w, h); - ctx.push_clip_layer(&outline); - let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); - paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); - ctx.pop_layer(); - } - // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its - // preview picture, stretched over the frame, as PowerPoint draws it. - ShapeKind::Opaque { preview: Some(media), .. } => { - // An embedded object's fallback picture can carry a fill (old equation objects - // drawn on coloured boxes): behind the preview, as for a picture. - if s.fill.as_ref().is_some_and(|f| !f.is_none()) - && let Some(fl) = &fill - { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); - } - ctx.set_transform(m); - let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; - paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None); - } - 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 = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; - ctx.push_clip_layer(&outline); - paint::picture(ctx, f.pres, &pf, bounds, m, None); - ctx.pop_layer(); - } 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() && s.line.is_none() && s.style.is_none()) - { - self.fill_geometry(ctx, f, rctx, fl, fill_ph, &geo, (w, h), m, &fade); - } - } - } - // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply - // a gradient over a tiled picture), inside the filled outline, under the line. - if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) - && fill.as_ref().is_some_and(|f| !f.is_none()) - { - let mix = match ov.blend.as_str() { - "mult" => peniko::Mix::Multiply, - "screen" => peniko::Mix::Screen, - "darken" => peniko::Mix::Darken, - "lighten" => peniko::Mix::Lighten, - _ => peniko::Mix::Normal, - }; - for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) { - ctx.set_transform(m); - ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); - paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); - ctx.pop_layer(); - } - } - // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). - let line = match (&s.geom, line.join) { - (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { - Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } - } - _ => line, - }; - // A 3-D bevel: the face and a band inside the outline lit as surfaces. - if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { - let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; - let solid = match &fill { - Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), - _ => None, - }; - let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none()); - if filled { - let mut area = BezPath::new(); - for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { - area.extend(sp.path.iter()); - } - if is_pic || area.elements().is_empty() { - area = outline.clone(); - } - bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); - } - } - // Outline. - if line.fill.as_ref().is_some_and(|f| !f.is_none()) { - let lw = line.width.unwrap_or(0.75).max(0.0); - let (base, rel) = self.line_space(m, depth); - // Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed - // outlines or on presets like brackets. - let ends = s.is_line() - || geo.is_open() - || (matches!(s.geom, Geom::Custom { .. }) - && geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)))); - let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) }; - let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none")); - // A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into - // a layer that is then faded, so they don't darken where they meet. - let see_through = match &line.fill { - Some(Fill::Solid { color: c }) if has_heads => Some(rctx.color(c, line_ph)).filter(|c| c.a < 255), - _ => None, - }; - let line = match see_through { - Some(c) => { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None); - std::borrow::Cow::Owned(Line { - fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), - ..line.clone() - }) - } - None => std::borrow::Cow::Borrowed(&line), - }; - // A picture's border lies outside the picture, as PowerPoint draws it: stroked twice - // as wide, outside the outline only (see `border_outside`). - let outside = border_outside(is_pic, effects.as_ref()).then(|| outside_line(&line, lw)).flatten(); - let clipped = outside.is_some(); - let (line, lw) = match outside { - Some(o) => { - let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1); - clip.extend(outline.iter()); - ctx.set_transform(m); - ctx.set_fill_rule(peniko::Fill::EvenOdd); - ctx.push_clip_layer(&clip); - ctx.set_fill_rule(peniko::Fill::NonZero); - (std::borrow::Cow::Owned(o), lw * 2.0) - } - None => (line, lw), - }; - let first_stroked = geo.paths.iter().position(|p| p.stroke); - for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { - let mut path = if rel == Affine::IDENTITY { - std::borrow::Cow::Borrowed(&sp.path) - } else { - let mut moved = sp.path.clone(); - moved.apply_affine(rel); - std::borrow::Cow::Owned(moved) - }; - // The line stops inside its arrowheads, so a wide line doesn't poke out of them. - let whole = path.clone(); - if Some(i) == first_stroked && trims != (0.0, 0.0) { - path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1)); - } - stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); - } - if ends { - arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw); - } - if clipped { - ctx.pop_layer(); - } - if see_through.is_some() { - ctx.pop_layer(); - } - } - if let Some(r) = soft { - // Feather: the shape (fill and line) fades to nothing at its outline. Measured on - // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of - // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. - ctx.set_transform(m); - ctx.push_layer( - None, - Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), - None, - None, - Some(blur(r / 3.0 * scale_of(m))), - ); - ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - ctx.fill_path(&outline); - ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); - ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); - ctx.stroke_path(&outline); - ctx.pop_layer(); - 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; - // As PowerPoint draws it (measured on its export): the shadow falls inside along the - // edges it points to (45 degrees: the bottom and right), blurred by a third of the - // blur radius. - ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * 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 - { - // Text in a group is set at the shape's size on the slide, in points: a group's - // child-coordinate scale sizes and places the box, never the type (#233's twin). - let (tw, th, m) = self.text_space(m, depth, w, h); - let text_geo; - let geo = if (tw, th) == (w, h) { - &geo - } else { - text_geo = shape_geometry(s, tw, th); - &text_geo - }; - let tr = geo.text_rect; - let editing = f.opts.edit && f.opts.editing_text == Some(s.id); - if !body.is_empty() || editing { - // Imported placeholders can have no paragraph until the first keystroke. - let mut empty; - let body = if editing && body.paragraphs.is_empty() { - empty = body.clone(); - empty.paragraphs.push(Default::default()); - &empty - } else { - body - }; - let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); - if editing { - preview_empty_bullets(&mut l); - } - let tm = text_transform(m, tr, l.rotation); - for b in l.lines.iter().filter_map(line_bounds) { - // Glyphs reach a little past their carets and line box (italics, swashes). - let pad = b.height() * 0.3; - self.track(tm, b.inflate(pad, pad)); - } - match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) { - Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w), - None => draw_layout_with(ctx, &l, tr, m, &st.paras), - } - } 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, - fill_alpha: Option, - outline: Option, - content: Option<&deckcraft_model::style::PictureFill>, - 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 local = shadow_local(sh, w, h); - // Shadow offset is in slide space (doesn't rotate with the shape unless asked). - let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; - let sm = pre * m * own; - ctx.set_transform(Affine::IDENTITY); - // Blurred by a third of the blur radius, as PowerPoint's export shows. - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); - ctx.set_transform(sm); - match content { - // As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a - // transparent background casts the logo's shadow): the picture, clipped to the - // shape, recoloured to the shadow colour. - Some(pf) => { - ctx.push_clip_layer(&silhouette); - paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None); - // Drawing the picture leaves its own pixel transform set: back to the shape's, - // or the recolouring below covers only part of a large picture's shadow. - ctx.set_transform(sm); - ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0)); - ctx.pop_layer(); - ctx.pop_layer(); - } - // The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as - // see-through as its fill (none for an outline-only shape) … - None => { - if let Some(a) = fill_alpha { - let mut filled = BezPath::new(); - for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { - filled.extend(sp.path.iter()); - } - ctx.set_paint(color(c.with_alpha(255), a)); - ctx.fill_path(&filled); - } - } - } - // … and the outline (a picture's border too). - if let Some(o) = &outline { - let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() }; - let lw = o.line.width.unwrap_or(0.75).max(0.0); - for sp in geo.paths.iter().filter(|sp| sp.stroke) { - let mut path = sp.path.clone(); - path.apply_affine(o.rel * own); - stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha); - } - } - ctx.pop_layer(); - } - // A shape with no fill and no line (a text box) draws nothing to glow around: its text's - // own glow is all PowerPoint shows, not a halo round the box. - let glows = fill_alpha.is_some() || content.is_some() || outline.is_some(); - if let Some(g) = e.glow.as_ref().filter(|_| glows) { - let c = rctx.color(&g.color, ph); - ctx.set_transform(Affine::IDENTITY); - // Measured on PowerPoint's export: a glow of radius r is half strength about half of r - // out from the outline and gone by r. - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * 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) * 0.95).with_join(kurbo::Join::Round)); - ctx.stroke_path(&silhouette); - // A shape without a fill (an outline, a frame) glows along its line only (#225). - if fill_alpha.is_some() || content.is_some() { - ctx.fill_path(&silhouette); - } - ctx.pop_layer(); - } - if let Some(rf) = &e.reflection { - // 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); - match content { - // A picture reflects its pixels (it has no fill of its own). - Some(pf) => { - ctx.push_clip_layer(&silhouette); - paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), mm, None); - ctx.pop_layer(); - } - None => { - 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); - } - } - } - ctx.set_transform(mm); - // 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(); - } - } -} - -/// Whether a shape's border is drawn outside its outline: a picture's is, unless the picture is -/// drawn in 3-D (a bevel seen through a 3-D scene), where PowerPoint centres it on the edge as on -/// any shape (observed: an 18 pt border covers 9 pt of the picture then). -fn border_outside(is_pic: bool, effects: Option<&Effects>) -> bool { - is_pic && !effects.is_some_and(|e| e.bevel.is_some() && e.raw3d.as_deref().is_some_and(|r| r.contains("scene3d"))) -} - -/// The line that draws `line` (`lw` wide) outside a picture: twice as wide, so the half outside -/// the outline is the border, with dashes kept their length. `None` for compound lines, whose -/// bands would not survive the doubling (they stay centred on the outline). -fn outside_line(line: &Line, lw: f64) -> Option { - if line.compound.is_some_and(|c| c != deckcraft_model::style::Compound::Single) { - return None; - } - let dash = line.dash.as_ref().map(|d| { - let p = d.pattern(); - deckcraft_model::style::Dash::Custom { - pattern: p.chunks(2).map(|c| (c.first().copied().unwrap_or(0.0) / 2.0, c.get(1).copied().unwrap_or(0.0) / 2.0)).collect(), - } - }); - Some(Line { width: Some(lw * 2.0), dash: dash.or_else(|| line.dash.clone()), ..line.clone() }) -} - -/// A shape's outline as it casts a shadow: its line, that line's placeholder colour and opacity, -/// and the space it is stroked in (see `Renderer::line_space`). -struct Caster<'a> { - line: std::borrow::Cow<'a, Line>, - ph: Option, - alpha: f64, - base: Affine, - rel: Affine, -} - -/// How opaque a fill is overall (`None` when nothing is filled): a solid colour's alpha, a -/// gradient's mean stop alpha, otherwise opaque. -fn fill_opacity(rctx: &Ctx, fill: &Option, ph: Option) -> Option { - match fill { - None | Some(Fill::None) => None, - Some(Fill::Solid { color: c }) => Some(rctx.color(c, ph).a as f64 / 255.0), - Some(Fill::Gradient(g)) if !g.stops.is_empty() => { - Some(g.stops.iter().map(|s| rctx.color(&s.color, ph).a as f64 / 255.0).sum::() / g.stops.len() as f64) - } - Some(_) => Some(1.0), - } -} - -/// A shadow's own scale and skew (`sx`, `sy`, `kx`, `ky`) in the shape's space, about the point -/// of the shape's box its alignment names (`algn`: `b` is the middle of the bottom edge, `tl` the -/// top-left corner…), as PowerPoint applies them: skewed, then scaled. A perspective shadow -/// (`algn="b"`, `sy` 23%, `kx` -20°) so lies flat along the shape's bottom edge, not around its -/// middle. -fn shadow_local(sh: &deckcraft_model::style::Shadow, w: f64, h: f64) -> Affine { - let (ax, ay) = match sh.align.as_str() { - "tl" => (0.0, 0.0), - "t" => (0.5, 0.0), - "tr" => (1.0, 0.0), - "l" => (0.0, 0.5), - "r" => (1.0, 0.5), - "bl" => (0.0, 1.0), - "b" => (0.5, 1.0), - "br" => (1.0, 1.0), - _ => (0.5, 0.5), - }; - let a = Vec2::new(w * ax, h * ay); - let (tx, ty) = (sh.kx.to_radians().tan(), sh.ky.to_radians().tan()); - let k = Affine::new([sh.sx, sh.sy * ty, sh.sx * tx, sh.sy, 0.0, 0.0]); - if !k.is_finite() { - return Affine::IDENTITY; - } - Affine::translate(a) * k * Affine::translate(-a) -} - -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 }) -} - -/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the -/// line belongs to; `path` itself when `None`). -#[allow(clippy::too_many_arguments)] -pub(crate) fn stroke( - ctx: &mut RenderContext, - rctx: &Ctx, - line: &Line, - ph: Option, - path: &BezPath, - extent: Option<&BezPath>, - m: Affine, - lw: f64, - alpha: f64, -) { - use deckcraft_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 sw = lw.max(min_w); - // PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`). - let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0); - // Square caps on a dashed line square off only the line's own two ends (where a dash is - // drawn): each dash itself ends flat, as PowerPoint draws them. - let mut square_ends = None; - if let Some(d) = &line.dash { - let pat: Vec = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); - let pat = dash_with_caps(pat, cap, sw); - let pat = limit_dash_count(path, pat); - if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) { - if cap == kurbo::Cap::Square { - sk = sk.with_caps(kurbo::Cap::Butt); - square_ends = Some(square_dash_ends(path, &pat, sw)); - } - 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), alpha)); - } - Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)), - _ => ctx.set_paint(color(Rgba::BLACK, alpha)), - } - // Compound lines: each band stroked along the path moved to its place across the width. - let compound = line.compound.unwrap_or_default(); - if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 { - let tolerance = 0.1 / scale_of(m).max(1e-6); - for &(a, b) in compound::bands(compound) { - let d = (-0.5 + (a + b) / 2.0) * lw; - let mut band = sk.clone(); - band.width = (b - a) * lw; - ctx.set_stroke(band); - ctx.stroke_path(&compound::offset(path, d, tolerance)); - } - return; - } - ctx.set_stroke(sk); - ctx.stroke_path(path); - if let Some(ends) = square_ends { - ctx.fill_path(&ends); - } -} - -/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square -/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end -/// when the pattern is in a dash there. -fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { - use kurbo::{ParamCurve, ParamCurveArclen}; - let mut out = BezPath::new(); - let period: f64 = pattern.iter().sum(); - let mut cap = |at: Point, dir: Vec2| { - let n = Vec2::new(-dir.y, dir.x) * (width / 2.0); - let back = dir * (width / 2.0); - out.move_to(at + n); - out.line_to(at + n + back); - out.line_to(at - n + back); - out.line_to(at - n); - out.close_path(); - }; - let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| { - let d = seg.eval(t1) - seg.eval(t0); - let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() }; - if d.hypot() > 1e-9 { Some(d.normalize()) } else { None } - }; - let mut subs: Vec<(Vec, bool)> = vec![]; - let mut cur: Vec = vec![]; - let mut closed = false; - let mut last = Point::ZERO; - let mut start = Point::ZERO; - for el in path.elements() { - let seg = match *el { - PathEl::MoveTo(p) => { - if !cur.is_empty() { - subs.push((std::mem::take(&mut cur), closed)); - } - closed = false; - last = p; - start = p; - continue; - } - PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), - PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), - PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), - PathEl::ClosePath => { - closed = true; - last = start; - continue; - } - }; - last = seg.end(); - cur.push(seg); - } - if !cur.is_empty() { - subs.push((cur, closed)); - } - for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) { - let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue }; - if let Some(d) = dir(first, 1e-3, 0.0) { - cap(first.start(), d); - } - let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum(); - if !(period > 0.0 && len.is_finite()) { - continue; - } - let mut pos = len % period; - // Ending exactly on a period boundary is the end of a gap. - let mut on = pos > period * 1e-6; - for v in pattern.iter().cycle().take(pattern.len()) { - if pos <= *v { - break; - } - pos -= v; - on = !on; - } - if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) { - cap(end.end(), d); - } - } - out -} - -/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the -/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs -/// across its width. -fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient { - let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0); - let bounds = paint::tile_box(bounds, g.tile); - let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect(); - paint::gradient(&g.shape, stops, bounds, alpha) -} - -/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As -/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a -/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid -/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a -/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see -/// `square_dash_ends`.) -fn dash_with_caps(mut pat: Vec, cap: kurbo::Cap, width: f64) -> Vec { - if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) { - return pat; - } - for (i, v) in pat.iter_mut().enumerate() { - *v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width }; - } - pat -} - -/// The most dashes one stroke is cut into. Stroking expands every dash into its own outline, so a -/// tiny dash on a huge shape (a 1 EMU-wide `custDash` on a 2147483647 EMU rectangle) would -/// otherwise need gigabytes and abort the app when the slide is drawn. Past this count the -/// pattern is scaled up evenly, which looks the same: the dashes are far below a pixel by then. -const MAX_DASHES: f64 = 20_000.0; - -fn limit_dash_count(path: &BezPath, pat: Vec) -> Vec { - use kurbo::ParamCurveArclen; - let period: f64 = pat.iter().sum(); - if !(period.is_finite() && period > 0.0) { - return pat; - } - let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); - let dashes = len / period; - if !len.is_finite() { - return vec![]; - } - if dashes <= MAX_DASHES { - return pat; - } - let k = dashes / MAX_DASHES; - pat.into_iter().map(|v| v * k).collect() -} - -fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { - let els: Vec = path.elements().to_vec(); - let mut pts: Vec = vec![]; - 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))) -} - -/// An arrowhead's width and length for a line `lw` wide. PowerPoint sizes them in multiples of -/// the line width (small 2, medium 3, large 5), but never smaller than for a 2 pt line. -fn head_size(e: &deckcraft_model::style::LineEnd, lw: f64) -> (f64, f64) { - let sz = |s: &str| match s { - "sm" => 2.0, - "lg" => 5.0, - _ => 3.0, - }; - let k = lw.max(2.0); - (sz(&e.w) * k, sz(&e.len) * k) -} - -/// How far back from the tip an open arrowhead's legs (`lw` wide) meet along their centre lines: -/// PowerPoint puts the outside of the point, not the legs' centre lines, on the line's end. -fn open_arrow_setback(wl: f64, ll: f64, lw: f64) -> f64 { - let half = (wl / 2.0).atan2(ll); - if half.sin() > 1e-6 { (lw / 2.0 / half.sin()).min(ll) } else { 0.0 } -} - -/// How much of a `lw`-wide line to leave off at its start and end, under its arrowheads: up to -/// a triangle's base, into a stealth arrow's notch far enough for its barbs to cover the line's -/// corners, and to where an open arrow's legs meet. Ovals and diamonds sit on the end itself. -fn head_trims(line: &Line, lw: f64) -> (f64, f64) { - // A square cap reaches half a width past the line's end: leave that off too. - let cap = if line.cap == Some(deckcraft_model::style::LineCap::Square) { lw / 2.0 } else { 0.0 }; - let trim = |e: &Option| { - let Some(e) = e else { return 0.0 }; - let (wl, ll) = head_size(e, lw); - match e.kind.as_str() { - "triangle" => ll + cap, - "stealth" => (ll * (0.6 + 0.4 * lw / wl.max(1e-9))).min(ll) + cap, - "arrow" => open_arrow_setback(wl, ll, lw.max(0.75)) + cap, - _ => 0.0, - } - }; - (trim(&line.head), trim(&line.tail)) -} - -/// `path` with `start` left off the beginning of its first sub-path and `end` off the end of its -/// last (by arc length), where those are open. A sub-path shorter than its trims disappears. -fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath { - use kurbo::{ParamCurve, ParamCurveArclen}; - const ACC: f64 = 1e-3; - let mut subs: Vec<(Vec, bool)> = vec![]; - let (mut last, mut first) = (Point::ZERO, Point::ZERO); - for el in path.elements() { - let seg = match *el { - PathEl::MoveTo(p) => { - subs.push((vec![], false)); - last = p; - first = p; - continue; - } - PathEl::ClosePath => { - if let Some(s) = subs.last_mut() { - s.1 = true; - } - last = first; - continue; - } - PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), - PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), - PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), - }; - last = seg.end(); - if subs.is_empty() { - subs.push((vec![], false)); - } - if let Some(s) = subs.last_mut() { - s.0.push(seg); - } - } - if let Some((segs, false)) = subs.first_mut() - && start > 0.0 - { - let mut left = start; - let mut drop_count = 0; - for (i, seg) in segs.iter().enumerate() { - let l = seg.arclen(ACC); - if l > left { - let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0); - segs.drain(..i); - segs[0] = partial; - drop_count = 0; - break; - } - left -= l; - drop_count = i + 1; - } - if drop_count > 0 { - segs.drain(..drop_count); - } - } - if let Some((segs, false)) = subs.last_mut() - && end > 0.0 - { - let mut left = end; - while let Some(seg) = segs.last().copied() { - let l = seg.arclen(ACC); - if l > left { - if let Some(s1) = segs.last_mut() { - *s1 = seg.subsegment(0.0..seg.inv_arclen(l - left, ACC)); - } - break; - } - left -= l; - segs.pop(); - } - } - let mut out = BezPath::new(); - for (segs, closed) in subs { - let Some(s0) = segs.first() else { continue }; - out.move_to(s0.start()); - for seg in &segs { - match seg { - kurbo::PathSeg::Line(l) => out.line_to(l.p1), - kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), - kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), - } - } - if closed { - out.close_path(); - } - } - out -} - -#[allow(clippy::too_many_arguments)] -fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, 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 }; - // The outline's ends in the space the arrowheads are sized and drawn in. - let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2); - let col = match &line.fill { - Some(Fill::Solid { color: c }) => rctx.color(c, ph), - _ => Rgba::BLACK, - }; - // A gradient line's heads take the gradient where they are. - let grad = match &line.fill { - Some(Fill::Gradient(g)) if !g.stops.is_empty() => { - let mut moved = sp.path.clone(); - moved.apply_affine(rel); - Some(line_gradient(rctx, g, ph, &moved, lw, 1.0)) - } - _ => None, - }; - let set_paint = |ctx: &mut RenderContext| match &grad { - Some(g) => ctx.set_paint(g.clone()), - None => ctx.set_paint(color(col, 1.0)), - }; - 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 (wl, ll) = head_size(e, 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" => { - // Legs the line's width with round ends, meeting in a sharp point whose outside is - // on the line's end. - let leg = lw.max(0.75); - let back = dir * open_arrow_setback(wl, ll, leg); - let mut o = BezPath::new(); - o.move_to(base + n * wl / 2.0 - back); - o.line_to(tip - back); - o.line_to(base - n * wl / 2.0 - back); - ctx.set_transform(m); - set_paint(ctx); - ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round)); - 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); - set_paint(ctx); - 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(deckcraft_model::text::Anchor::Bottom); - if let Some(p) = t.paragraphs.first_mut() { - p.props.align = Some(deckcraft_model::text::Align::Center); - p.props.bullet = Some(deckcraft_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()); -} - -pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { - let key = (face.id(), gid); - if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { - return p; - } - 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 = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: prompt, no_shrink: false }); - draw_layout(ctx, &l, tr, m); -} - -/// Add transient list markers to an editor layout, using the document's own bullet glyphs. -fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) { - for mut run in l.empty_bullets.drain(..) { - run.color = Rgba::rgb(0xB3, 0xB3, 0xB3); - run.alpha = 1.0; - l.runs.push(run); - } -} - -/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local → -/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre. -/// -/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's -/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about -/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180° -/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this. -pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { - let c = tr.center().to_vec2(); - let mut t = m; - if m.determinant() < 0.0 && c.is_finite() { - t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c); - } - if rotation != 0.0 && rotation.is_finite() && c.is_finite() { - t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c); - } - t -} - -/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws. -pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option { - let bp = resolve::body(rctx, s); - let name = bp.warp.as_deref()?; - deckcraft_geom::warp::warp(name, &bp.warp_adj, tr) -} - -/// Draw an already laid-out text block. -pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { - draw_layout_with(ctx, l, tr, m, &[]); -} - -/// Draw a laid-out text block with per-paragraph animation states. -pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { - let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); - let m = text_transform(m, tr, l.rotation); - let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { - let ps = para(d.para); - if !ps.visible || ps.opacity <= 0.001 { - return; - } - ctx.set_transform(m * Affine::translate(ps.offset)); - ctx.set_paint(color(d.color, ps.opacity)); - fill_deco(ctx, d); - }; - for d in l.decos.iter().filter(|d| d.behind) { - deco(ctx, d); - } - // Text shadows, then glows, under all the text. - for pass in [TextEffect::Shadow, TextEffect::Glow] { - for run in &l.runs { - let ps = para(run.para); - if ps.visible && ps.opacity > 0.001 { - text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity); - } - } - } - for run in &l.runs { - if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { - continue; - } - let ps = para(run.para); - if !ps.visible || ps.opacity <= 0.001 { - continue; - } - let m = m * Affine::translate(ps.offset); - // A gradient fill starts over on every line and spans that line's text (from its top to - // its bottom, from its first to its last character), as in PowerPoint. - let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new(); - for (gid, x, y) in &run.glyphs { - let at = m * glyph_place(run, *gid, *x, *y); - let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { - draw_glyph(ctx, run, *gid, at, ps.opacity); - continue; - }; - let li = line_at(l, run.para, *y); - if !grads.iter().any(|(i, _)| *i == li) { - let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; - let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); - let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr); - grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity))); - } - let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y)))); - draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill); - } - } - for d in l.decos.iter().filter(|d| !d.behind) { - deco(ctx, d); - } -} - -/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a -/// quarter turn back about its em box's centre when it stands upright in vertical text. -pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine { - let at = Affine::translate((x, y)); - match run.upright_centre(gid, x, y) { - Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at, - None => at, - } -} - -/// Fill an underline, strikethrough or highlight with the current paint and transform: its box, -/// its dashes, or its zigzag stroked. -pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { - if let Some((pts, w)) = d.wave() { - let mut path = BezPath::new(); - for (i, p) in pts.iter().enumerate() { - if i == 0 { - path.move_to(*p); - } else { - path.line_to(*p); - } - } - ctx.set_stroke(kurbo::Stroke::new(w.max(0.01))); - ctx.stroke_path(&path); - return; - } - for r in d.pieces() { - ctx.fill_rect(&r); - } -} - -/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). -fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { - if let Some(&(first, count)) = l.para_lines.get(para) { - if count == 0 { - return 0; - } - let end = (first + count).min(l.lines.len()); - if first < end { - return l.lines[first..end] - .iter() - .enumerate() - .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) - .map(|(i, _)| first + i) - .unwrap_or(0); - } - } - l.lines - .iter() - .enumerate() - .filter(|(_, li)| li.para == para) - .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) - .map(|(i, _)| i) - .unwrap_or(0) -} - -/// The box a line's text covers: its top and bottom, its first and last caret. -fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { - let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x))); - let r = Rect::new(x0, li.top, x1, li.bottom); - (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) -} - -#[derive(Clone, Copy, PartialEq)] -enum TextEffect { - Shadow, - Glow, -} - -/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. -fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { - let Some(fx) = run.effects else { return }; - let k = run.size / run.face.upem.max(1.0); - let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - let px = scale_of(m); - let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) { - (TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0), - // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r - // past the glyph's edge and fades out by about two thirds of r. - (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68), - _ => return, - }; - let alpha = (c.a as f64 / 255.0) * opacity; - if alpha <= 0.0 { - return; - } - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma))); - ctx.set_paint(color(c.with_alpha(255), 1.0)); - for (gid, x, y) in &run.glyphs { - let path = glyph(&run.face, *gid); - if path.elements().is_empty() { - continue; - } - // The offset is in slide space: a shadow falls the same way whatever the text's rotation. - let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k); - ctx.set_transform(at); - ctx.fill_path(&path); - if grow > 0.0 { - ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - } - } - ctx.pop_layer(); -} - -/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. -pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { - draw_glyph_with(ctx, run, gid, at, opacity, None); -} - -/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from -/// the glyph's origin to that space) instead of the run's colour. -fn draw_glyph_with( - ctx: &mut RenderContext, - run: &deckcraft_text::GlyphRun, - gid: u32, - at: Affine, - opacity: f64, - fill: Option<(&peniko::Gradient, Affine)>, -) { - let alpha = run.alpha * opacity; - if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { - return; - } - let k = run.size / run.face.upem.max(1.0); - // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still - // strokes the plain glyph below. - if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { - return; - } - let path = glyph(&run.face, gid); - if path.elements().is_empty() { - return; - } - let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; - ctx.set_transform(at * skew * Affine::scale(k)); - if let Some((g, to_text)) = fill - && alpha > 0.0 - { - ctx.set_paint(g.clone()); - ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text); - ctx.fill_path(&path); - ctx.reset_paint_transform(); - } else if alpha > 0.0 && run.color.a > 0 { - ctx.set_paint(color(run.color, alpha)); - 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, opacity)); - ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); - ctx.stroke_path(&path); - } -} - -/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). -pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { - if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { - return None; - } - let rctx = Ctx::for_slide(pres, slide)?; - let mut items = Vec::new(); - let mut bounds: Option = None; - for id in ids.iter().take(256) { - let Some(s) = slide.shape(*id) else { continue }; - let b = resolve::xfrm(&rctx, s).bounds(); - if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { - continue; - } - bounds = Some(match bounds { - Some(u) => u.union(b), - None => b, - }); - items.push(s); - } - let b = bounds?.inflate(8.0, 8.0); - if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { - return None; - } - let fit = (2048.0 / b.width().max(b.height())).min(scale); - if !(fit.is_finite() && fit > 0.0) { - return None; - } - let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; - let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; - let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - let opts = RenderOpts { scale: fit, ..Default::default() }; - let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; - let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); - RENDERER.with(|r| { - let mut r = r.borrow_mut(); - for s in items { - r.shape(&mut ctx, &f, &rctx, s, view, 0); - } - Some(r.finish(ctx, w, h)) - }) -} - -/// Render one shape alone onto a transparent canvas (drag previews, copy as picture). -pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { - let rctx = Ctx::for_slide(pres, slide)?; - 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::new(1, String::new(), &opts) }; - 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)) - }) -} +mod geometry; +mod opts; +mod raster; +mod renderer; +mod slide; +mod stroke; +mod text_draw; + +use geometry::shade; +pub use geometry::{camera_turn, crop_dest, parse_path, shape_geometry}; +pub(crate) use geometry::{color, scale_of}; +use opts::SlideFields; +pub use opts::{ParaState, RenderOpts, ShapeState}; +pub use raster::{Image, MAX_SIDE, dpi_for_scale, image_dpi}; +#[cfg(test)] +use renderer::RENDERER; +pub use renderer::Renderer; +pub use slide::{ + SlidePart, needs_filters, panics_caught, render_blend, render_layout, render_shape, render_shapes, render_slide, render_slide_drawn, + render_slide_part, +}; +pub(crate) use stroke::stroke; +#[cfg(test)] +use stroke::{MAX_DASHES, dash_with_caps, head_size, limit_dash_count, trim_ends}; +use text_draw::preview_empty_bullets; +pub(crate) use text_draw::{draw_glyph, fill_deco, glyph, glyph_place}; +pub use text_draw::{draw_layout, draw_layout_with, draw_text, text_transform, text_warp}; #[cfg(test)] mod tests; diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs new file mode 100644 index 000000000..898d84cc0 --- /dev/null +++ b/crates/render/src/opts.rs @@ -0,0 +1,132 @@ +use super::{DateTime, Fields, Rgba, ShapeId, Vec2, datetime}; + +/// 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, + /// Per-paragraph states of by-paragraph text builds (paragraph index, state). + pub paras: Vec<(usize, ParaState)>, +} + +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, paras: vec![] } + } +} + +/// Animation state of one paragraph, relative to its shape. +#[derive(Clone, Copy, Debug)] +pub struct ParaState { + pub visible: bool, + pub opacity: f64, + /// Translation in shape-local points. + pub offset: Vec2, +} + +impl Default for ParaState { + fn default() -> Self { + ParaState { visible: true, opacity: 1.0, offset: Vec2::ZERO } + } +} + +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, + /// Text being edited: replace its prompt with gray markers on empty list paragraphs. + /// Ignored outside editor rendering. + pub editing_text: Option, + /// Animated GIF pictures and how far into their play they are (seconds); others show frame 1. + pub gif_times: &'a [(ShapeId, f64)], + /// 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, + /// Show this text in every date field instead of the date. + pub date_text: Option, + /// The local date and time date fields show (`None`: the system clock). + pub now: Option, + /// Leave the slide background out (neither the white underlay nor the background fill), so + /// only what is on the slide is drawn over transparency: PNG export with a transparent + /// background. Shapes filled with the background (`useBgFill`) still show it. + pub transparent_background: bool, +} + +impl Default for RenderOpts<'_> { + fn default() -> Self { + RenderOpts { + scale: 1.0, + edit: false, + show_hidden: false, + state: None, + skip: &[], + skip_text: None, + editing_text: None, + gif_times: &[], + threads: 0, + offset: (0.0, 0.0), + size: None, + clear: None, + date_text: None, + now: None, + transparent_background: false, + } + } +} + +pub(crate) struct SlideFields { + pub(crate) num: u32, + /// Text for every date field instead of the date ([`RenderOpts::date_text`]). + pub(crate) date: Option, + pub(crate) footer: String, + /// The local date and time date fields show; `None`: they keep their saved text. + pub(crate) now: Option, +} + +impl SlideFields { + pub(crate) fn new(num: u32, footer: String, opts: &RenderOpts) -> Self { + SlideFields { num, date: opts.date_text.clone(), footer, now: opts.now.or_else(DateTime::now_local) } + } +} + +impl Fields for SlideFields { + fn field(&self, kind: &str) -> Option { + self.field_in(kind, None) + } + + fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { + if kind == "slidenum" { + return Some(self.num.to_string()); + } + if datetime::is_date_field(kind) { + // An automatic date: today's, in the format of its type. (A fixed date is plain text + // in the slide's placeholder, not a field.) + return self.date.clone().or_else(|| datetime::field_text(kind, lang, self.now?)); + } + if kind == "footer" { + return Some(self.footer.clone()); + } + None + } +} diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs index e9c9255ce..9e8df451d 100644 --- a/crates/render/src/radar.rs +++ b/crates/render/src/radar.rs @@ -67,7 +67,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec Align::Right => q.x - w + base * 0.3, _ => q.x - w / 2.0, }; - label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false); + label( + ctx, + rctx, + m, + name, + Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), + crate::chart::LabelStyle { size: base, align, anchor: Anchor::Middle, col: text_col, bold: false }, + ); } } // Value labels up the first spoke. @@ -83,11 +90,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, &t, Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6), - base * 0.9, - Align::Right, - Anchor::Middle, - text_col, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Right, anchor: Anchor::Middle, col: text_col, bold: false }, ); } } @@ -130,11 +133,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec m, &t, Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2), - base * 0.9, - Align::Center, - Anchor::Bottom, - text_col, - false, + crate::chart::LabelStyle { size: base * 0.9, align: Align::Center, anchor: Anchor::Bottom, col: text_col, bold: false }, ); } } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs new file mode 100644 index 000000000..19ba78255 --- /dev/null +++ b/crates/render/src/raster.rs @@ -0,0 +1,113 @@ +use super::*; + +/// 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 + } + /// One pixel as straight (unpremultiplied) RGBA; `None` outside the image. + pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> { + if x >= self.width || y >= self.height { + return None; + } + let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?; + let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?; + if a == 0 || a == 255 { + return Some([r, g, b, a]); + } + let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8; + Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a]) + } + pub fn to_png(&self) -> Vec { + let mut px = self.to_straight(); + px.resize(self.width as usize * self.height as usize * 4, 0); + 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 + } + + /// PNG of this image whose `pHYs` chunk says `dpi` dots per inch. + /// + /// Slide space is points, 72 per inch, so a render at `scale` pixels per point is + /// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a + /// Retina (2×) export opens at twice the slide's size. + pub fn to_png_at(&self, dpi: f64) -> Vec { + images::stamp_png_dpi(&self.to_png(), dpi) + } + + pub fn to_jpeg(&self, quality: u8) -> Vec { + self.to_jpeg_at(quality, None) + } + + /// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range. + pub fn to_jpeg_at(&self, quality: u8, dpi: Option) -> Vec { + let straight = self.to_straight(); + let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; + let mut buf = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + if let Some(d) = dpi.and_then(dpi_u16) { + enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d)); + } + if let Err(e) = img.write_with_encoder(enc) { + log::error!("JPEG encode: {e}"); + return Vec::new(); + } + 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] + } +} + +/// Dots per inch for a slide drawn at `pixels_per_point`. +/// +/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi: +/// the same pixels, at the slide's real size, on a Retina display. +pub fn dpi_for_scale(pixels_per_point: f64) -> f64 { + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return 72.0; + } + (pixels_per_point * 72.0).clamp(1.0, 10_000.0) +} + +fn dpi_u16(dpi: f64) -> Option { + if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) { + return None; + } + Some(dpi.round() as u16) +} + +/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch. +pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> { + images::dpi(bytes) +} diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs new file mode 100644 index 000000000..fdabb2136 --- /dev/null +++ b/crates/render/src/renderer.rs @@ -0,0 +1,1190 @@ +use super::geometry::{Caster, blur, border_outside, camera_turn, color, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry}; +use super::opts::{RenderOpts, ShapeState, SlideFields}; +use super::raster::Image; +use super::slide::{SlidePart, needs_filters, output_size}; +use super::stroke::{arrowheads, head_trims, stroke, trim_ends}; +use super::text_draw::{draw_layout_with, draw_text, line_bounds, preview_empty_bullets, prompt_body, text_transform, text_warp}; +use super::*; + +pub struct Renderer { + resources: Resources, + /// Outlines morphing in the frame being drawn by `blend`. + paths: Vec, + /// The background of the slide (layout, master) being drawn, its placeholder colour and the + /// slide's view transform: shapes filled with the background (`useBgFill`) show it through. + bg: Option<(Fill, Option, Affine)>, + /// What the frame being drawn has painted so far, in output pixels: the editor learns from it + /// how far a slide's drawing (text spilling out of its box too) reaches off the slide. + pub(crate) drawn: Option, + /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw + /// under it combined with the group's child-coordinate mapping, which must not scale lines. + view: Affine, + /// The fill of the group being drawn, with the transform and box it is laid out in: children + /// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group. + group_fill: Option<(Fill, Affine, Rect)>, + /// Their aligned outlines, kept from frame to frame. + morphs: morph_path::Cache, + /// Whether we are currently drawing an outer-shadow silhouette pass for an ancestor group. + in_shadow_pass: bool, + /// Cumulative shape traversal counter to prevent nested group amplification DoS. + render_work: usize, + /// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget + /// of the normal drawing that follows. + shadow_work: usize, +} + +impl Default for Renderer { + fn default() -> Self { + Renderer { + resources: Resources::new(), + paths: Vec::new(), + bg: None, + drawn: None, + view: Affine::IDENTITY, + group_fill: None, + morphs: morph_path::Cache::default(), + in_shadow_pass: false, + render_work: 0, + shadow_work: 0, + } + } +} + +thread_local! { + pub(crate) static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); +} + +pub(crate) struct Frame<'a> { + pub(crate) pres: &'a Presentation, + pub(crate) opts: &'a RenderOpts<'a>, + pub(crate) fields: SlideFields, +} + +/// A shape being drawn: its resolved geometry, fill, line and effects, in shape space `m` at size +/// `w` x `h`, shared by the stages of `Renderer::geometry_shape`. +struct ShapeDraw<'a, 'ctx> { + f: &'a Frame<'a>, + rctx: &'a Ctx<'ctx>, + s: &'a Shape, + st: &'a ShapeState, + m: Affine, + w: f64, + h: f64, + depth: usize, + geo: Geometry, + fades: Vec, + fill: Option, + fill_ph: Option, + line: Line, + line_ph: Option, + effects: Option, + fx_ph: Option, + outline: BezPath, + is_pic: bool, + empty_ph: bool, +} + +impl ShapeDraw<'_, '_> { + /// How much of the fill and stroke of path `i` shows (morphing outlines fade). + fn fade(&self, i: usize) -> deckcraft_geom::morph::Fade { + self.fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }) + } +} + +/// What casts a shape's shadow, glow and reflection: its geometry, fill and outline, in shape +/// space `m` at size `w` x `h`. +struct FxSource<'a> { + geo: &'a Geometry, + fill: &'a Option, + fill_alpha: Option, + outline: Option>, + content: Option<&'a deckcraft_model::style::PictureFill>, + m: Affine, + w: f64, + h: f64, +} + +impl Renderer { + fn canvas(pres: &Presentation, opts: &RenderOpts, filters: bool) -> (RenderContext, u16, u16, Affine) { + 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") || filters { 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); + (ctx, w, h, view) + } + + /// Note that `r` (local space, placed by `m`) was drawn. + fn track(&mut self, m: Affine, r: Rect) { + let b = m.transform_rect_bbox(r); + if b.is_finite() { + self.drawn = Some(self.drawn.map_or(b, |d| d.union(b))); + } + } + + pub(crate) fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields { + SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts) + } + + /// Background plus the master and layout graphics the slide shows. + fn backdrop(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: &Slide, view: Affine) { + self.background(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(f.pres, rctx.master); + for s in &rctx.master.shapes { + if s.ph.is_none() { + self.shape(ctx, f, &mctx, s, view, 0); + } + } + } + if show_layout && let Some(l) = rctx.layout { + let lctx = Ctx::for_layout(f.pres, rctx.master, l); + for s in &l.shapes { + if s.ph.is_none() { + self.shape(ctx, f, &lctx, s, view, 0); + } + } + } + } + + pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image { + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); + let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; + if let Some(rctx) = Ctx::for_slide(pres, slide) { + self.backdrop(&mut ctx, &f, &rctx, slide, view); + for s in &slide.shapes { + self.shape(&mut ctx, &f, &rctx, s, view, 0); + } + } + self.finish(ctx, w, h) + } + + pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide)); + let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) }; + if let Some(rctx) = Ctx::for_slide(pres, slide) { + match part { + SlidePart::Backdrop => self.backdrop(&mut ctx, &f, &rctx, slide, view), + SlidePart::Content => { + // Shapes filled with the background (`useBgFill`) still show it. + let (fill, ph) = resolve::background(&rctx, Some(slide)); + self.bg = Some((fill, ph, view)); + for s in &slide.shapes { + self.shape(&mut ctx, &f, &rctx, s, view, 0); + } + } + } + } + self.finish(ctx, w, h) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn blend( + &mut self, + pres: &Presentation, + old: (&Slide, usize), + new: (&Slide, usize), + mix: f64, + shapes: &[(Shape, bool)], + text: &[TextMorph], + paths: &[PathMorph], + opts: &RenderOpts, + ) -> Image { + let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 }; + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0)); + let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) }; + // Backdrop graphics never take shape states: their ids may clash with the frame's shapes. + let bopts = RenderOpts { state: None, date_text: opts.date_text.clone(), ..*opts }; + if mix < 1.0 { + let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, old.1, opts) }; + self.backdrop(&mut ctx, &f, &co, old.0, view); + } + if mix > 0.0 { + let f = Frame { pres, opts: &bopts, fields: Self::fields(pres, new.1, opts) }; + let layered = mix < 1.0; + if layered { + ctx.push_layer(None, None, Some(mix as f32), None, None); + } + self.backdrop(&mut ctx, &f, &cn, new.0, view); + if layered { + ctx.pop_layer(); + } + } + let fo = Frame { pres, opts, fields: Self::fields(pres, old.1, opts) }; + let fnew = Frame { pres, opts, fields: Self::fields(pres, new.1, opts) }; + let mut drawn: Vec = Vec::new(); + // Set only now: the backdrop's ids may clash with the frame's. + self.paths = paths.to_vec(); + for (s, from_old) in shapes { + let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) }; + let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else { + self.shape(&mut ctx, f, c, s, view, 0); + continue; + }; + self.shape(&mut ctx, f, c, &Shape { text: None, ..s.clone() }, view, 0); + drawn.push(s.id); + if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) { + continue; + } + let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id); + if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) { + let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b)); + morph_text::draw(&mut ctx, (&co, a, &fo.fields), (&cn, b, &fnew.fields), tm, now, view); + } + } + self.paths.clear(); + self.morphs.sweep(); + self.finish(ctx, w, h) + } + + pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option, opts: &RenderOpts) -> Image { + self.render_work = 0; + self.shadow_work = 0; + self.in_shadow_pass = false; + 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::new(1, String::new(), opts) }; + 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) + } + + pub(crate) 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); + if f.opts.transparent_background { + self.bg = Some((fill, ph, view)); + return; + } + 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); + // PowerPoint lays every background over white: a picture stretched into part of the + // slide, or a see-through colour, leaves white around and under it. + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_path(&path); + paint::fill_path(ctx, rctx, &fill, ph, &path, r, view, FillMode::Norm, 1.0, f.pres); + self.bg = Some((fill, ph, view)); + } + + pub(crate) fn shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) { + if depth == 0 { + let outer = std::mem::replace(&mut self.view, parent); + self.shape_at(ctx, f, rctx, s, parent, depth); + self.view = outer; + } else { + self.shape_at(ctx, f, rctx, s, parent, depth); + } + } + + /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the + /// one that takes the shape's outline into it. Inside a group they are the slide-level view and + /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but + /// never the line width; outside a group, `m` itself and no change to the outline. + fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) { + let view = self.view; + if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) } + } + + /// Shape-space units per point: a group's child coordinates can scale a shape's box (a legacy + /// 1360-unit child space makes one unit hundreds of EMUs), but 3-D sizes kept in points + /// (contour width, extrusion depth, bevel) stay points, as PowerPoint draws them. 1 outside a + /// group. + fn per_point(&self, m: Affine, depth: usize) -> f64 { + let k = scale_of(self.line_space(m, depth).1); + if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 } + } + + /// The size and transform a shape's text is set in: inside a group whose child coordinates + /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so + /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves. + fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) { + let (_, rel) = self.line_space(m, depth); + let c = rel.as_coeffs(); + let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3])); + let ok = |k: f64| k.is_finite() && k > 1e-9; + if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) { + return (w, h, m); + } + let (tw, th) = (w * sx, h * sy); + if !tw.is_finite() || !th.is_finite() { + return (w, h, m); + } + (tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy)) + } + + /// A shape's geometry in its own `w` × `h` space, built at its size on the slide: inside a + /// group whose child coordinates scale the shape unevenly, the preset's rounded corners and + /// other size-derived features stay as they would be on a plain shape of that size (#1083). + fn scaled_geometry(&self, s: &Shape, m: Affine, depth: usize, w: f64, h: f64) -> Geometry { + let (tw, th, _) = self.text_space(m, depth, w, h); + if (tw, th) == (w, h) { + return shape_geometry(s, w, h); + } + let mut geo = shape_geometry(s, tw, th); + let k = |a: f64, b: f64| if b.abs() > 1e-12 { a / b } else { 1.0 }; + let back = Affine::scale_non_uniform(k(w, tw), k(h, th)); + for p in &mut geo.paths { + p.path.apply_affine(back); + } + geo.text_rect = back.transform_rect_bbox(geo.text_rect); + for p in &mut geo.sites { + *p = back * *p; + } + geo + } + + fn shape_at(&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; + } + let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work }; + *work = work.saturating_add(1); + if *work > 100_000 { + 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; + } + if let Some(a) = camera_turn(rctx, s, x.w, x.h) { + m *= a; + } + 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 * deckcraft_geom::group_child_affine(&x, ch); + // A group's own fill (a group filled with its parent's passes that on). + let own = match &s.fill { + Some(Fill::Group) | None => self.group_fill.clone(), + Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), + }; + let outer = std::mem::replace(&mut self.group_fill, own); + // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the + // children drawn once more under the shadow's offset, scale and skew, recoloured to + // the shadow colour and blurred, below the group. + if !self.in_shadow_pass + && let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) + { + let c = rctx.color(&sh.color, None); + let k = scale_of(m); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + let local = shadow_local(sh, x.w, x.h); + let (own, pre) = + if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; + let det = m.determinant(); + if det.is_finite() && det.abs() > 1e-12 { + let shift = pre * m * own * m.inverse(); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); + self.in_shadow_pass = true; + for ch in children { + self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); + } + self.in_shadow_pass = false; + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); + ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); + ctx.pop_layer(); + ctx.pop_layer(); + } + } + for c in children { + self.shape(ctx, f, rctx, c, gm, depth + 1); + } + self.group_fill = outer; + } + ShapeKind::Table(t) => { + // Rows grow with their text: what the cells cover, borders included. + let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id)); + self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0)); + } + ShapeKind::Chart(c) => { + self.track(m, Rect::new(0.0, 0.0, x.w, x.h)); + chart::draw(ctx, rctx, c, m, x.w, x.h) + } + ShapeKind::Ink { strokes } => { + for sk in strokes { + let legacy = deckcraft_model::ink_pressure_is_legacy(&sk.points); + let pen = if sk.width.is_finite() { sk.width.abs() } else { 0.0 }; + for (px, py, pr) in &sk.points { + let w = deckcraft_model::ink_sample_width(pen, *pr, legacy); + self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w)); + } + let pts: Vec<(f64, f64, f32)> = sk.points.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()).collect(); + if pts.len() < 2 { + continue; + } + let (base, rel) = self.line_space(parent, depth); + ctx.set_transform(base); + ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 })); + // Uniform-width and highlighter strokes are one path: segments drawn one by one + // overlap at the joins, which darkens see-through highlighter ink in beads. + if legacy || sk.highlighter { + let mut bp = BezPath::new(); + for (i, (px, py, _)) in pts.iter().enumerate() { + if i == 0 { + bp.move_to((*px, *py)); + } else { + bp.line_to((*px, *py)); + } + } + bp.apply_affine(rel); + ctx.set_stroke(kurbo::Stroke::new(pen.max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + continue; + } + for pair in pts.windows(2) { + let [p0, p1] = pair else { continue }; + let mut bp = BezPath::new(); + bp.move_to((p0.0, p0.1)); + bp.line_to((p1.0, p1.1)); + bp.apply_affine(rel); + let a = deckcraft_model::ink_sample_width(pen, p0.2, legacy); + let b = deckcraft_model::ink_sample_width(pen, p1.2, legacy); + ctx.set_stroke(kurbo::Stroke::new(((a + b) * 0.5).max(0.1)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&bp); + } + } + } + _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth), + } + if layered { + ctx.pop_layer(); + } + } + + /// Fill the filled sub-paths of `geo` (a `w` × `h` shape placed by `m`) with `fl`, faded per + /// sub-path by `fade`: `useBgFill` shows the slide background through the shape and `grpFill` + /// the group's fill. + #[allow(clippy::too_many_arguments)] + fn fill_geometry( + &self, + ctx: &mut RenderContext, + f: &Frame, + rctx: &Ctx, + fl: &Fill, + fill_ph: Option, + geo: &Geometry, + (w, h): (f64, f64), + m: Affine, + fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade, + ) { + for (i, sp) in geo.paths.iter().enumerate() { + if sp.fill == FillMode::None || fade(i).fill <= 0.0 { + continue; + } + ctx.set_transform(m); + ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + match (fl, &self.bg) { + // `useBgFill`: the slide's background, laid out on the slide (not the + // shape), shows through the shape. + (Fill::Background, Some((bg, ph, view))) => { + let slide = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height); + let path = (view.inverse() * m) * sp.path.clone(); + paint::fill_path(ctx, rctx, bg, *ph, &path, slide, *view, sp.fill, fade(i).fill, f.pres); + } + // `grpFill`: the group's fill, laid out over the whole group. + (Fill::Group, _) => { + if let Some((gf, gm, gbox)) = &self.group_fill + && gm.determinant().abs() > 1e-12 + { + let path = (gm.inverse() * m) * sp.path.clone(); + paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres); + } + } + _ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres), + } + ctx.set_fill_rule(peniko::Fill::NonZero); + } + } + + #[allow(clippy::too_many_arguments)] + fn geometry_shape( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + rctx: &Ctx, + s: &Shape, + m: Affine, + w: f64, + h: f64, + st: &ShapeState, + depth: usize, + ) { + let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { + Some(pm) => self.morphs.geometry(pm, s, w, h), + None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()), + }; + 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 reach = extent::spill(Some(&line), effects.as_ref(), w, h); + self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach)); + 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(deckcraft_model::ColorRef::rgb(t))) } else { fill }; + let d = ShapeDraw { f, rctx, s, st, m, w, h, depth, geo, fades, fill, fill_ph, line, line_ph, effects, fx_ph, outline, is_pic, empty_ph }; + self.cast_effects(ctx, &d); + let soft = d.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; + } + let pt = self.point_scale(&d); + self.draw_extrusion(ctx, &d, pt); + self.draw_contour(ctx, &d, pt); + self.draw_fill(ctx, &d); + self.draw_fill_overlay(ctx, &d); + self.draw_bevel(ctx, &d, pt); + self.draw_outline(ctx, &d); + if let Some(r) = soft { + self.draw_feather(ctx, &d, r); + } + self.draw_inner_shadow(ctx, &d); + self.draw_text_body(ctx, &d); + } + + /// Shape-space units per point for the 3-D sizes, measured without the camera's turn (which + /// `shape_at` puts in `m`): its foreshortening is not a child-coordinate scale. + fn point_scale(&self, d: &ShapeDraw) -> f64 { + let turn = d + .effects + .as_ref() + .and_then(|e| e.raw3d.as_deref()) + .and_then(camera::from_raw) + .and_then(|c| camera::affine(&c, d.w, d.h)) + .filter(|t| t.determinant().abs() > 1e-9); + self.per_point(turn.map_or(d.m, |t| d.m * t.inverse()), d.depth) + } + + fn cast_effects(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let fill_ph = d.fill_ph; + let line_ph = d.line_ph; + let fx_ph = d.fx_ph; + let is_pic = d.is_pic; + let s = d.s; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let line = &d.line; + // Effects below the shape. + if let Some(e) = &effects { + // A picture (or picture fill) casts its shadow from its pixels, not its frame. + let content = match (&s.kind, &fill) { + (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()), + _ => None, + }; + // What casts the shadow: the fill as see-through as it is, and the outline. + let fill_alpha = fill_opacity(rctx, fill, fill_ph); + let outline = line.fill.as_ref().filter(|f| !f.is_none()).map(|lf| { + let a = match lf { + Fill::Solid { color: c } => rctx.color(c, line_ph).a as f64 / 255.0, + _ => 1.0, + }; + let (base, rel) = self.line_space(m, depth); + let lw = line.width.unwrap_or(0.75).max(0.0); + let outside = if border_outside(is_pic, effects.as_ref()) { outside_line(line, lw) } else { None }; + Caster { line: outside.map_or(std::borrow::Cow::Borrowed(line), std::borrow::Cow::Owned), ph: line_ph, alpha: a, base, rel } + }); + self.effects_below(ctx, rctx, e, fx_ph, FxSource { geo, fill, fill_alpha, outline, content: content.as_ref(), m, w, h }); + } + } + + fn draw_extrusion(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let rctx = d.rctx; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; + // A 3-D extrusion under a turned camera: the sides, swept behind the face. + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(ex) = extrude::from_raw(raw) + && let Some(cam) = camera::from_raw(raw) + && let Some(turn) = camera::affine(&cam, w, h) + && turn.determinant().abs() > 1e-9 + { + let face = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => None, + }; + let pre = m * turn.inverse(); + let lit = light::Lighting::from_raw(raw); + let d = ex.depth * pt; + extrude::draw(ctx, outline, w, h, &cam, d, pre, turn, extrude::side_color(rctx, &ex, face), &lit); + } + } + + fn draw_contour(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let fill_ph = d.fill_ph; + let rctx = d.rctx; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; + // A 3-D contour: a band around the outline, behind the face (and in front of any sides). + if let Some(raw) = effects.as_ref().and_then(|e| e.raw3d.as_deref()) + && let Some(c) = extrude::contour_from_raw(raw) + { + let face = match &fill { + Some(Fill::Solid { color: col }) => Some(rctx.color(col, fill_ph)), + _ => None, + }; + let col = c.color.as_ref().map(|k| rctx.color(k, face)).or(face).unwrap_or(Rgba::rgb(0xff, 0xff, 0xff)); + extrude::draw_contour(ctx, outline, m, c.width * pt, col, &light::Lighting::from_raw(raw)); + } + } + + fn draw_fill(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let empty_ph = d.empty_ph; + let s = d.s; + let f = d.f; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let outline = &d.outline; + let fade = |i: usize| d.fade(i); + // Fill. + match &s.kind { + ShapeKind::Picture { fill: pf } => { + // The picture's own fill shows behind its transparent pixels, as in PowerPoint (an + // emphasis tint recolours it, as it does any shape's fill). + if s.fill.as_ref().is_some_and(|f| !f.is_none()) + && let Some(fl) = &fill + { + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + } + ctx.set_transform(m); + let bounds = Rect::new(0.0, 0.0, w, h); + ctx.push_clip_layer(outline); + let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1); + paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t); + ctx.pop_layer(); + } + // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its + // preview picture, stretched over the frame, as PowerPoint draws it. + ShapeKind::Opaque { preview: Some(media), .. } => { + // An embedded object's fallback picture can carry a fill (old equation objects + // drawn on coloured boxes): behind the preview, as for a picture. + if s.fill.as_ref().is_some_and(|f| !f.is_none()) + && let Some(fl) = &fill + { + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + } + ctx.set_transform(m); + let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() }; + paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None); + } + 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 = deckcraft_model::style::PictureFill { media: p, crop: mc.crop, ..Default::default() }; + ctx.push_clip_layer(outline); + paint::picture(ctx, f.pres, &pf, bounds, m, None); + ctx.pop_layer(); + } 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() && s.line.is_none() && s.style.is_none()) + { + self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade); + } + } + } + } + + fn draw_fill_overlay(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fx_ph = d.fx_ph; + let f = d.f; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let fade = |i: usize| d.fade(i); + // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply + // a gradient over a tiled picture), inside the filled outline, under the line. + if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref()) + && fill.as_ref().is_some_and(|f| !f.is_none()) + { + let mix = match ov.blend.as_str() { + "mult" => peniko::Mix::Multiply, + "screen" => peniko::Mix::Screen, + "darken" => peniko::Mix::Darken, + "lighten" => peniko::Mix::Lighten, + _ => peniko::Mix::Normal, + }; + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) { + ctx.set_transform(m); + ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None); + paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres); + ctx.pop_layer(); + } + } + } + + fn draw_bevel(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, pt: f64) { + let m = d.m; + let w = d.w; + let h = d.h; + let fill_ph = d.fill_ph; + let is_pic = d.is_pic; + let rctx = d.rctx; + let geo = &d.geo; + let fill = &d.fill; + let effects = &d.effects; + let outline = &d.outline; + // A 3-D bevel: the face and a band inside the outline lit as surfaces. + if let Some(bv) = effects.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(bevel::from_raw) { + let bv = bevel::Bevel { w: bv.w * pt, h: bv.h * pt, ..bv }; + let solid = match &fill { + Some(Fill::Solid { color: c }) => Some(rctx.color(c, fill_ph)), + _ => None, + }; + let filled = is_pic || fill.as_ref().is_some_and(|f| !f.is_none()); + if filled { + let mut area = BezPath::new(); + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { + area.extend(sp.path.iter()); + } + if is_pic || area.elements().is_empty() { + area = outline.clone(); + } + bevel::draw(ctx, &bv, if is_pic { None } else { solid }, &area, Rect::new(0.0, 0.0, w, h), m, 1.0); + } + } + } + + fn draw_outline(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let line_ph = d.line_ph; + let is_pic = d.is_pic; + let s = d.s; + let rctx = d.rctx; + let geo = &d.geo; + let effects = &d.effects; + let outline = &d.outline; + let line = d.line.clone(); + let fade = |i: usize| d.fade(i); + // Some presets' outlines default to sharp corners (PowerPoint's own per-shape default). + let line = match (&s.geom, line.join) { + (Geom::Preset { name, .. }, None) if preset::mitred_by_default(name) => { + Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..line } + } + _ => line, + }; + // Outline. + if line.fill.as_ref().is_some_and(|f| !f.is_none()) { + let lw = line.width.unwrap_or(0.75).max(0.0); + let (base, rel) = self.line_space(m, depth); + // Arrowheads go on lines, arcs and open freeforms (filled or not), never on closed + // outlines or on presets like brackets. + let ends = s.is_line() + || geo.is_open() + || (matches!(s.geom, Geom::Custom { .. }) + && geo.paths.iter().find(|p| p.stroke).is_some_and(|p| !p.path.elements().iter().any(|e| matches!(e, PathEl::ClosePath)))); + let trims = if ends { head_trims(&line, lw) } else { (0.0, 0.0) }; + let has_heads = ends && [&line.head, &line.tail].iter().any(|e| e.as_ref().is_some_and(|e| e.kind != "none")); + // A see-through line and its arrowheads are one shape in PowerPoint: drawn opaque into + // a layer that is then faded, so they don't darken where they meet. + let see_through = match &line.fill { + Some(Fill::Solid { color: c }) if has_heads => Some(rctx.color(c, line_ph)).filter(|c| c.a < 255), + _ => None, + }; + let line = match see_through { + Some(c) => { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(c.a as f32 / 255.0), None, None); + std::borrow::Cow::Owned(Line { + fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), + ..line.clone() + }) + } + None => std::borrow::Cow::Borrowed(&line), + }; + // A picture's border lies outside the picture, as PowerPoint draws it: stroked twice + // as wide, outside the outline only (see `border_outside`). + let outside = border_outside(is_pic, effects.as_ref()).then(|| outside_line(&line, lw)).flatten(); + let clipped = outside.is_some(); + let (line, lw) = match outside { + Some(o) => { + let mut clip = Rect::new(0.0, 0.0, w, h).inflate(w + h + lw * 8.0, w + h + lw * 8.0).to_path(0.1); + clip.extend(outline.iter()); + ctx.set_transform(m); + ctx.set_fill_rule(peniko::Fill::EvenOdd); + ctx.push_clip_layer(&clip); + ctx.set_fill_rule(peniko::Fill::NonZero); + (std::borrow::Cow::Owned(o), lw * 2.0) + } + None => (line, lw), + }; + let first_stroked = geo.paths.iter().position(|p| p.stroke); + for (i, sp) in geo.paths.iter().enumerate().filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0) { + let mut path = if rel == Affine::IDENTITY { + std::borrow::Cow::Borrowed(&sp.path) + } else { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + std::borrow::Cow::Owned(moved) + }; + // The line stops inside its arrowheads, so a wide line doesn't poke out of them. + let whole = path.clone(); + if Some(i) == first_stroked && trims != (0.0, 0.0) { + path = std::borrow::Cow::Owned(trim_ends(&path, trims.0, trims.1)); + } + stroke(ctx, rctx, &line, line_ph, &path, Some(&whole), base, lw, fade(i).stroke); + } + if ends { + arrowheads(ctx, rctx, &line, line_ph, geo, rel, base, lw); + } + if clipped { + ctx.pop_layer(); + } + if see_through.is_some() { + ctx.pop_layer(); + } + } + } + + fn draw_feather(&mut self, ctx: &mut RenderContext, d: &ShapeDraw, r: f64) { + let m = d.m; + let outline = &d.outline; + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. + ctx.set_transform(m); + ctx.push_layer( + None, + Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), + None, + None, + Some(blur(r / 3.0 * scale_of(m))), + ); + ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + ctx.fill_path(outline); + ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + ctx.stroke_path(outline); + ctx.pop_layer(); + ctx.pop_layer(); + ctx.pop_layer(); + } + + fn draw_inner_shadow(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let fx_ph = d.fx_ph; + let rctx = d.rctx; + let effects = &d.effects; + let outline = &d.outline; + // 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; + // As PowerPoint draws it (measured on its export): the shadow falls inside along the + // edges it points to (45 degrees: the bottom and right), blurred by a third of the + // blur radius. + ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * 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(); + } + } + + fn draw_text_body(&mut self, ctx: &mut RenderContext, d: &ShapeDraw) { + let m = d.m; + let w = d.w; + let h = d.h; + let depth = d.depth; + let empty_ph = d.empty_ph; + let s = d.s; + let f = d.f; + let rctx = d.rctx; + let st = d.st; + let geo = &d.geo; + // 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 + { + // Text in a group is set at the shape's size on the slide, in points: a group's + // child-coordinate scale sizes and places the box, never the type (#233's twin). + let (tw, th, m) = self.text_space(m, depth, w, h); + let text_geo; + let geo = if (tw, th) == (w, h) { + geo + } else { + text_geo = shape_geometry(s, tw, th); + &text_geo + }; + let tr = geo.text_rect; + let editing = f.opts.edit && f.opts.editing_text == Some(s.id); + if !body.is_empty() || editing { + // Imported placeholders can have no paragraph until the first keystroke. + let mut empty; + let body = if editing && body.paragraphs.is_empty() { + empty = body.clone(); + empty.paragraphs.push(Default::default()); + &empty + } else { + body + }; + let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false }); + if editing { + preview_empty_bullets(&mut l); + } + let tm = text_transform(m, tr, l.rotation); + for b in l.lines.iter().filter_map(line_bounds) { + // Glyphs reach a little past their carets and line box (italics, swashes). + let pad = b.height() * 0.3; + self.track(tm, b.inflate(pad, pad)); + } + match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) { + Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w), + None => draw_layout_with(ctx, &l, tr, m, &st.paras), + } + } 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, m, &Opts { rect: tr, fields: &f.fields, prompt_color: Some(grey), no_shrink: false }); + } + } + } + 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, src: FxSource) { + let FxSource { geo, fill, fill_alpha, outline, content, m, w, h } = src; + 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 local = shadow_local(sh, w, h); + // Shadow offset is in slide space (doesn't rotate with the shape unless asked). + let (own, pre) = if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; + let sm = pre * m * own; + ctx.set_transform(Affine::IDENTITY); + // Blurred by a third of the blur radius, as PowerPoint's export shows. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); + ctx.set_transform(sm); + match content { + // As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a + // transparent background casts the logo's shadow): the picture, clipped to the + // shape, recoloured to the shadow colour. + Some(pf) => { + ctx.push_clip_layer(&silhouette); + paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None); + // Drawing the picture leaves its own pixel transform set: back to the shape's, + // or the recolouring below covers only part of a large picture's shadow. + ctx.set_transform(sm); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0)); + ctx.pop_layer(); + ctx.pop_layer(); + } + // The shadow is cast by what is drawn, as PowerPoint casts it: the filled part as + // see-through as its fill (none for an outline-only shape) … + None => { + if let Some(a) = fill_alpha { + let mut filled = BezPath::new(); + for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) { + filled.extend(sp.path.iter()); + } + ctx.set_paint(color(c.with_alpha(255), a)); + ctx.fill_path(&filled); + } + } + } + // … and the outline (a picture's border too). + if let Some(o) = &outline { + let cast = Line { fill: Some(Fill::solid(deckcraft_model::style::ColorRef::rgb(c.with_alpha(255)))), ..o.line.as_ref().clone() }; + let lw = o.line.width.unwrap_or(0.75).max(0.0); + for sp in geo.paths.iter().filter(|sp| sp.stroke) { + let mut path = sp.path.clone(); + path.apply_affine(o.rel * own); + stroke(ctx, rctx, &cast, o.ph, &path, None, pre * o.base, lw, o.alpha); + } + } + ctx.pop_layer(); + } + // A shape with no fill and no line (a text box) draws nothing to glow around: its text's + // own glow is all PowerPoint shows, not a halo round the box. + let glows = fill_alpha.is_some() || content.is_some() || outline.is_some(); + if let Some(g) = e.glow.as_ref().filter(|_| glows) { + let c = rctx.color(&g.color, ph); + ctx.set_transform(Affine::IDENTITY); + // Measured on PowerPoint's export: a glow of radius r is half strength about half of r + // out from the outline and gone by r. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * 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) * 0.95).with_join(kurbo::Join::Round)); + ctx.stroke_path(&silhouette); + // A shape without a fill (an outline, a frame) glows along its line only (#225). + if fill_alpha.is_some() || content.is_some() { + ctx.fill_path(&silhouette); + } + ctx.pop_layer(); + } + if let Some(rf) = &e.reflection { + // 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); + match content { + // A picture reflects its pixels (it has no fill of its own). + Some(pf) => { + ctx.push_clip_layer(&silhouette); + paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), mm, None); + ctx.pop_layer(); + } + None => { + 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); + } + } + } + ctx.set_transform(mm); + // 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(); + } + } +} diff --git a/crates/render/src/slide.rs b/crates/render/src/slide.rs new file mode 100644 index 000000000..b3e6e5dec --- /dev/null +++ b/crates/render/src/slide.rs @@ -0,0 +1,229 @@ +use super::opts::{RenderOpts, SlideFields}; +use super::raster::{Image, MAX_SIDE}; +use super::renderer::{Frame, RENDERER, Renderer}; +use super::{Affine, Ctx, PathMorph, Presentation, Rect, RenderContext, Shape, ShapeId, Slide, TextMorph, resolve}; + +static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn note_panic() { + PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. +pub fn panics_caught() -> u64 { + PANICS.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Does anything on this slide (or its layout/master graphics) use blur-based effects? +pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool { + let Some(rctx) = Ctx::for_slide(pres, slide) else { return false }; + let mut any = false; + let mut check = |shapes: &[Shape]| { + deckcraft_model::walk(shapes, &mut |s, _| { + if any { + return; + } + let (e, _) = resolve::effects(&rctx, s); + if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) { + any = true; + } + }); + }; + check(&slide.shapes); + if let Some(l) = rctx.layout { + check(&l.shapes); + } + check(&rctx.master.shapes); + any +} + +/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's +/// text styles it inherits)? Such text is blurred, which needs filters. +fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool { + use deckcraft_model::text::{ListStyle, RunProps}; + let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0); + let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run)); + let Some(t) = &s.text else { return false }; + t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props)) + || styled(&t.list_style) + || styled(&rctx.master.title_style) + || styled(&rctx.master.body_style) + || styled(&rctx.master.other_style) +} + +/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a +/// blank image (logged), never a crash. +pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image { + render_slide_drawn(pres, index, opts).0 +} + +/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the +/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output +/// included). The editor grows its pasteboard to it ([`pasteboard_region`]). +pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) { + let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height); + let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + r.drawn = None; + let img = r.slide(pres, slide, index, opts); + (img, r.drawn.take()) + }) + })) { + Ok((img, drawn)) => { + let k = opts.scale; + let to_slide = + |d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k); + let extent = match drawn { + Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)), + _ => page, + }; + (img, extent) + } + Err(_) => { + log::error!("rendering slide {index} panicked; showing a blank slide"); + note_panic(); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + let (w, h) = output_size(pres, opts); + (Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page) + } + } +} + +/// Which part of a slide [`render_slide_part`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SlidePart { + /// The background fill with the master and layout graphics. + Backdrop, + /// The slide's own shapes over transparency. + Content, +} + +/// Render one part of slide `index` (transitions that move the contents over the backgrounds). +/// A panic inside the renderer becomes a transparent image (logged), never a crash. +pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opts: &RenderOpts) -> Image { + let Some(slide) = pres.slides.get(index) else { return Image::default() }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide_part(pres, slide, index, part, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering part of slide {index} panicked; showing nothing"); + note_panic(); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + let (w, h) = output_size(pres, opts); + Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] } + } + } +} + +/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the +/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by +/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`: +/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or +/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw +/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies +/// to `shapes` only. +pub fn render_blend( + pres: &Presentation, + (old, new): (usize, usize), + mix: f64, + shapes: &[(Shape, bool)], + text: &[TextMorph], + paths: &[PathMorph], + opts: &RenderOpts, +) -> Image { + let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts)) + })) { + Ok(img) => img, + Err(_) => { + log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); + note_panic(); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + render_slide(pres, new, opts) + } + } +} + +/// Render a layout (Slide Master view thumbnail and canvas). +pub fn render_layout(pres: &Presentation, master: usize, layout: Option, opts: &RenderOpts) -> Image { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) { + Ok(img) => img, + Err(_) => { + log::error!("rendering a layout panicked; showing a blank image"); + note_panic(); + RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); + Image::default() + } + } +} + +pub(crate) 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) +} + +/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture). +pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option { + if ids.is_empty() || !scale.is_finite() || scale <= 0.0 { + return None; + } + let rctx = Ctx::for_slide(pres, slide)?; + let mut items = Vec::new(); + let mut bounds: Option = None; + for id in ids.iter().take(256) { + let Some(s) = slide.shape(*id) else { continue }; + let b = resolve::xfrm(&rctx, s).bounds(); + if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 { + continue; + } + bounds = Some(match bounds { + Some(u) => u.union(b), + None => b, + }); + items.push(s); + } + let b = bounds?.inflate(8.0, 8.0); + if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) { + return None; + } + let fit = (2048.0 / b.width().max(b.height())).min(scale); + if !(fit.is_finite() && fit > 0.0) { + return None; + } + let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16; + let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16; + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let opts = RenderOpts { scale: fit, ..Default::default() }; + let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) }; + let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2()); + RENDERER.with(|r| { + let mut r = r.borrow_mut(); + for s in items { + r.shape(&mut ctx, &f, &rctx, s, view, 0); + } + Some(r.finish(ctx, w, h)) + }) +} + +/// Render one shape alone onto a transparent canvas (drag previews, copy as picture). +pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option { + let rctx = Ctx::for_slide(pres, slide)?; + 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::new(1, String::new(), &opts) }; + 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)) + }) +} diff --git a/crates/render/src/stroke.rs b/crates/render/src/stroke.rs new file mode 100644 index 000000000..8310cf0c9 --- /dev/null +++ b/crates/render/src/stroke.rs @@ -0,0 +1,441 @@ +use super::geometry::{color, scale_of}; +use super::*; + +/// Stroke `path` with `line`. A gradient line is laid out over `extent` (the untrimmed outline the +/// line belongs to; `path` itself when `None`). +#[allow(clippy::too_many_arguments)] +pub(crate) fn stroke( + ctx: &mut RenderContext, + rctx: &Ctx, + line: &Line, + ph: Option, + path: &BezPath, + extent: Option<&BezPath>, + m: Affine, + lw: f64, + alpha: f64, +) { + use deckcraft_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 sw = lw.max(min_w); + // PowerPoint's default miter limit is 8 widths (`a:miter lim="800000"`). + let mut sk = kurbo::Stroke::new(sw).with_caps(cap).with_join(join).with_miter_limit(8.0); + // Square caps on a dashed line square off only the line's own two ends (where a dash is + // drawn): each dash itself ends flat, as PowerPoint draws them. + let mut square_ends = None; + if let Some(d) = &line.dash { + let pat: Vec = d.pattern().iter().map(|v| v * lw.max(0.75)).collect(); + let pat = dash_with_caps(pat, cap, sw); + let pat = limit_dash_count(path, pat); + if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) { + if cap == kurbo::Cap::Square { + sk = sk.with_caps(kurbo::Cap::Butt); + square_ends = Some(square_dash_ends(path, &pat, sw)); + } + 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), alpha)); + } + Some(Fill::Gradient(g)) if !g.stops.is_empty() => ctx.set_paint(line_gradient(rctx, g, ph, extent.unwrap_or(path), lw, alpha)), + _ => ctx.set_paint(color(Rgba::BLACK, alpha)), + } + // Compound lines: each band stroked along the path moved to its place across the width. + let compound = line.compound.unwrap_or_default(); + if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 { + let tolerance = 0.1 / scale_of(m).max(1e-6); + for &(a, b) in compound::bands(compound) { + let d = (-0.5 + (a + b) / 2.0) * lw; + let mut band = sk.clone(); + band.width = (b - a) * lw; + ctx.set_stroke(band); + ctx.stroke_path(&compound::offset(path, d, tolerance)); + } + return; + } + ctx.set_stroke(sk); + ctx.stroke_path(path); + if let Some(ends) = square_ends { + ctx.fill_path(&ends); + } +} + +/// The square caps of a dashed line drawn `width` wide with dash `pattern`: a half-width square +/// past the start of each open sub-path (every dash pattern starts with a dash) and past its end +/// when the pattern is in a dash there. +fn square_dash_ends(path: &BezPath, pattern: &[f64], width: f64) -> BezPath { + use kurbo::{ParamCurve, ParamCurveArclen}; + let mut out = BezPath::new(); + let period: f64 = pattern.iter().sum(); + let mut cap = |at: Point, dir: Vec2| { + let n = Vec2::new(-dir.y, dir.x) * (width / 2.0); + let back = dir * (width / 2.0); + out.move_to(at + n); + out.line_to(at + n + back); + out.line_to(at - n + back); + out.line_to(at - n); + out.close_path(); + }; + let dir = |seg: &kurbo::PathSeg, t0: f64, t1: f64| { + let d = seg.eval(t1) - seg.eval(t0); + let d = if d.hypot() > 1e-9 { d } else { seg.end() - seg.start() }; + if d.hypot() > 1e-9 { Some(d.normalize()) } else { None } + }; + let mut subs: Vec<(Vec, bool)> = vec![]; + let mut cur: Vec = vec![]; + let mut closed = false; + let mut last = Point::ZERO; + let mut start = Point::ZERO; + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + if !cur.is_empty() { + subs.push((std::mem::take(&mut cur), closed)); + } + closed = false; + last = p; + start = p; + continue; + } + PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), + PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), + PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), + PathEl::ClosePath => { + closed = true; + last = start; + continue; + } + }; + last = seg.end(); + cur.push(seg); + } + if !cur.is_empty() { + subs.push((cur, closed)); + } + for segs in subs.iter().filter(|(s, c)| !c && !s.is_empty()).map(|(s, _)| s) { + let (Some(first), Some(end)) = (segs.first(), segs.last()) else { continue }; + if let Some(d) = dir(first, 1e-3, 0.0) { + cap(first.start(), d); + } + let len: f64 = segs.iter().map(|s| s.arclen(1e-3)).sum(); + if !(period > 0.0 && len.is_finite()) { + continue; + } + let mut pos = len % period; + // Ending exactly on a period boundary is the end of a gap. + let mut on = pos > period * 1e-6; + for v in pattern.iter().cycle().take(pattern.len()) { + if pos <= *v { + break; + } + pos -= v; + on = !on; + } + if on && let Some(d) = dir(end, 1.0 - 1e-3, 1.0) { + cap(end.end(), d); + } + } + out +} + +/// A gradient line's paint: laid out, as PowerPoint lays it, over the box the stroke covers (the +/// path's bounds grown by half the line width), so a horizontal line's vertical gradient runs +/// across its width. +fn line_gradient(rctx: &Ctx, g: &deckcraft_model::style::Gradient, ph: Option, path: &BezPath, lw: f64, alpha: f64) -> peniko::Gradient { + let bounds = path.bounding_box().inflate(lw / 2.0, lw / 2.0); + let bounds = paint::tile_box(bounds, g.tile); + let stops: Vec<(f64, Rgba)> = g.stops.iter().map(|s| (s.pos, rctx.color(&s.color, ph))).collect(); + paint::gradient(&g.shape, stops, bounds, alpha) +} + +/// A dash pattern (on, off, on, off… lengths) for a line drawn `width` wide with `cap`. As +/// PowerPoint draws them, round caps sit inside each dash's length rather than adding to it: a +/// "round dot" (`sysDot`, one width on and one off) is a row of separate circles, not a solid +/// line. So each dash loses a width (half at each end, where the caps go) and each gap gains it; a +/// dash no longer than the width becomes just its two caps. (Square-capped dashes end flat; see +/// `square_dash_ends`.) +pub(crate) fn dash_with_caps(mut pat: Vec, cap: kurbo::Cap, width: f64) -> Vec { + if cap != kurbo::Cap::Round || !pat.len().is_multiple_of(2) || !(width.is_finite() && width > 0.0) { + return pat; + } + for (i, v) in pat.iter_mut().enumerate() { + *v = if i.is_multiple_of(2) { (*v - width).max(width * 1e-3) } else { *v + width }; + } + pat +} + +/// The most dashes one stroke is cut into. Stroking expands every dash into its own outline, so a +/// tiny dash on a huge shape (a 1 EMU-wide `custDash` on a 2147483647 EMU rectangle) would +/// otherwise need gigabytes and abort the app when the slide is drawn. Past this count the +/// pattern is scaled up evenly, which looks the same: the dashes are far below a pixel by then. +pub(crate) const MAX_DASHES: f64 = 20_000.0; + +pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec) -> Vec { + use kurbo::ParamCurveArclen; + let period: f64 = pat.iter().sum(); + if !(period.is_finite() && period > 0.0) { + return pat; + } + let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum(); + let dashes = len / period; + if !len.is_finite() { + return vec![]; + } + if dashes <= MAX_DASHES { + return pat; + } + let k = dashes / MAX_DASHES; + pat.into_iter().map(|v| v * k).collect() +} + +fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> { + let els: Vec = path.elements().to_vec(); + let mut pts: Vec = vec![]; + 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))) +} + +/// An arrowhead's width and length for a line `lw` wide. PowerPoint sizes them in multiples of +/// the line width (small 2, medium 3, large 5), but never smaller than for a 2 pt line. +pub(crate) fn head_size(e: &deckcraft_model::style::LineEnd, lw: f64) -> (f64, f64) { + let sz = |s: &str| match s { + "sm" => 2.0, + "lg" => 5.0, + _ => 3.0, + }; + let k = lw.max(2.0); + (sz(&e.w) * k, sz(&e.len) * k) +} + +/// How far back from the tip an open arrowhead's legs (`lw` wide) meet along their centre lines: +/// PowerPoint puts the outside of the point, not the legs' centre lines, on the line's end. +fn open_arrow_setback(wl: f64, ll: f64, lw: f64) -> f64 { + let half = (wl / 2.0).atan2(ll); + if half.sin() > 1e-6 { (lw / 2.0 / half.sin()).min(ll) } else { 0.0 } +} + +/// How much of a `lw`-wide line to leave off at its start and end, under its arrowheads: up to +/// a triangle's base, into a stealth arrow's notch far enough for its barbs to cover the line's +/// corners, and to where an open arrow's legs meet. Ovals and diamonds sit on the end itself. +pub(crate) fn head_trims(line: &Line, lw: f64) -> (f64, f64) { + // A square cap reaches half a width past the line's end: leave that off too. + let cap = if line.cap == Some(deckcraft_model::style::LineCap::Square) { lw / 2.0 } else { 0.0 }; + let trim = |e: &Option| { + let Some(e) = e else { return 0.0 }; + let (wl, ll) = head_size(e, lw); + match e.kind.as_str() { + "triangle" => ll + cap, + "stealth" => (ll * (0.6 + 0.4 * lw / wl.max(1e-9))).min(ll) + cap, + "arrow" => open_arrow_setback(wl, ll, lw.max(0.75)) + cap, + _ => 0.0, + } + }; + (trim(&line.head), trim(&line.tail)) +} + +/// `path` with `start` left off the beginning of its first sub-path and `end` off the end of its +/// last (by arc length), where those are open. A sub-path shorter than its trims disappears. +pub(crate) fn trim_ends(path: &BezPath, start: f64, end: f64) -> BezPath { + use kurbo::{ParamCurve, ParamCurveArclen}; + const ACC: f64 = 1e-3; + let mut subs: Vec<(Vec, bool)> = vec![]; + let (mut last, mut first) = (Point::ZERO, Point::ZERO); + for el in path.elements() { + let seg = match *el { + PathEl::MoveTo(p) => { + subs.push((vec![], false)); + last = p; + first = p; + continue; + } + PathEl::ClosePath => { + if let Some(s) = subs.last_mut() { + s.1 = true; + } + last = first; + continue; + } + PathEl::LineTo(p) => kurbo::PathSeg::Line(kurbo::Line::new(last, p)), + PathEl::QuadTo(a, p) => kurbo::PathSeg::Quad(kurbo::QuadBez::new(last, a, p)), + PathEl::CurveTo(a, b, p) => kurbo::PathSeg::Cubic(kurbo::CubicBez::new(last, a, b, p)), + }; + last = seg.end(); + if subs.is_empty() { + subs.push((vec![], false)); + } + if let Some(s) = subs.last_mut() { + s.0.push(seg); + } + } + if let Some((segs, false)) = subs.first_mut() + && start > 0.0 + { + let mut left = start; + let mut drop_count = 0; + for (i, seg) in segs.iter().enumerate() { + let l = seg.arclen(ACC); + if l > left { + let partial = seg.subsegment(seg.inv_arclen(left, ACC)..1.0); + segs.drain(..i); + segs[0] = partial; + drop_count = 0; + break; + } + left -= l; + drop_count = i + 1; + } + if drop_count > 0 { + segs.drain(..drop_count); + } + } + if let Some((segs, false)) = subs.last_mut() + && end > 0.0 + { + let mut left = end; + while let Some(seg) = segs.last().copied() { + let l = seg.arclen(ACC); + if l > left { + if let Some(s1) = segs.last_mut() { + *s1 = seg.subsegment(0.0..seg.inv_arclen(l - left, ACC)); + } + break; + } + left -= l; + segs.pop(); + } + } + let mut out = BezPath::new(); + for (segs, closed) in subs { + let Some(s0) = segs.first() else { continue }; + out.move_to(s0.start()); + for seg in &segs { + match seg { + kurbo::PathSeg::Line(l) => out.line_to(l.p1), + kurbo::PathSeg::Quad(q) => out.quad_to(q.p1, q.p2), + kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), + } + } + if closed { + out.close_path(); + } + } + out +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option, geo: &Geometry, rel: Affine, 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 }; + // The outline's ends in the space the arrowheads are sized and drawn in. + let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2); + let col = match &line.fill { + Some(Fill::Solid { color: c }) => rctx.color(c, ph), + _ => Rgba::BLACK, + }; + // A gradient line's heads take the gradient where they are. + let grad = match &line.fill { + Some(Fill::Gradient(g)) if !g.stops.is_empty() => { + let mut moved = sp.path.clone(); + moved.apply_affine(rel); + Some(line_gradient(rctx, g, ph, &moved, lw, 1.0)) + } + _ => None, + }; + let set_paint = |ctx: &mut RenderContext| match &grad { + Some(g) => ctx.set_paint(g.clone()), + None => ctx.set_paint(color(col, 1.0)), + }; + 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 (wl, ll) = head_size(e, 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" => { + // Legs the line's width with round ends, meeting in a sharp point whose outside is + // on the line's end. + let leg = lw.max(0.75); + let back = dir * open_arrow_setback(wl, ll, leg); + let mut o = BezPath::new(); + o.move_to(base + n * wl / 2.0 - back); + o.line_to(tip - back); + o.line_to(base - n * wl / 2.0 - back); + ctx.set_transform(m); + set_paint(ctx); + ctx.set_stroke(kurbo::Stroke::new(leg).with_join(kurbo::Join::Miter).with_miter_limit(100.0).with_caps(kurbo::Cap::Round)); + 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); + set_paint(ctx); + ctx.fill_path(&p); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index bcddf4c96..cddf1e373 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -616,14 +616,14 @@ fn blend_ends_match_the_slides_and_mixes_backdrops() { let o = RenderOpts { scale: 0.5, ..Default::default() }; let shapes = vec![(sh, false)]; // At the ends the frame is the slide itself; the shape stays opaque throughout. - assert_eq!(render_blend(&p, 0, 1, 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); - let mid = render_blend(&p, 0, 1, 0.5, &shapes, &[], &[], &o); + assert_eq!(render_blend(&p, (0, 1), 1.0, &shapes, &[], &[], &o).pixels, render_slide(&p, 1, &o).pixels); + let mid = render_blend(&p, (0, 1), 0.5, &shapes, &[], &[], &o); let px = mid.pixel(5, 5); assert!((px[0] as i32 - 128).abs() <= 3, "{px:?}"); assert_eq!(mid.pixel(100, 75), render_slide(&p, 1, &o).pixel(100, 75)); // Hostile input: bad indices and NaN never panic. - assert_eq!(render_blend(&p, 0, 9, 0.5, &shapes, &[], &[], &o).width, 0); - let _ = render_blend(&p, 0, 1, f64::NAN, &shapes, &[], &[], &o); + assert_eq!(render_blend(&p, (0, 9), 0.5, &shapes, &[], &[], &o).width, 0); + let _ = render_blend(&p, (0, 1), f64::NAN, &shapes, &[], &[], &o); } fn text_shape(id: u32, text: &str, x: f64, y: f64) -> Shape { @@ -668,7 +668,7 @@ fn morph_text_ends_match_the_slides() { sb.xfrm = Some(x); sa.xfrm = Some(x); let tm = TextMorph { old: ShapeId(11), new: ShapeId(10), from, to, chars: false, t }; - render_blend(&p, 0, 1, t, &[(sb, false), (sa, true)], &[tm], &[], &o) + render_blend(&p, (0, 1), t, &[(sb, false), (sa, true)], &[tm], &[], &o) }; // Warm up: faces load lazily (a background scan runs), so the first render may still fall back. let _ = (render_slide(&p, 0, &o), render_slide(&p, 1, &o), frame(0.5)); @@ -781,7 +781,7 @@ fn morph_path_ends_match_the_slides() { let lerp = |a: f64, b: f64| a + (b - a) * t; let x = Xfrm::new(lerp(from.x, to.x), lerp(from.y, to.y), lerp(from.w, to.w), lerp(from.h, to.h)); let pm = PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; - render_blend(&p, 0, 1, t, &[(form(10, "star5", x), false)], &[], &[pm], &o) + render_blend(&p, (0, 1), t, &[(form(10, "star5", x), false)], &[], &[pm], &o) }; // The morph splits the outlines into more curves, which flatten slightly differently: edge pixels // may differ a little, a different shape differs a lot. @@ -792,7 +792,7 @@ fn morph_path_ends_match_the_slides() { let d1 = differ(&frame(1.0), &render_slide(&p, 1, &o)); assert!(d1 < 40, "end differs {d1}"); // Without the morph the start would show the star. - let star = render_blend(&p, 0, 1, 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); + let star = render_blend(&p, (0, 1), 0.0, &[(form(10, "star5", from), false)], &[], &[], &o); assert!(differ(&star, &render_slide(&p, 0, &o)) > 400); // Halfway the shape is filled around the middle of its box. let mid = frame(0.5); @@ -818,7 +818,7 @@ fn morph_path_fades_a_fill_only_one_side_has() { let at = |t: f64| { let pm = PathMorph { id: ShapeId(10), from: Geom::preset("rect"), from_box: bx, to_box: bx, t }; // A point near the line but not on it: inside the shape all the way. - render_blend(&p, 0, 1, t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) + render_blend(&p, (0, 1), t, &[(form(10, "line"), false)], &[], &[pm], &o).pixel(110, 90) }; let (full, half, none) = (at(0.0), at(0.5), at(1.0)); assert_eq!(none, [255, 255, 255, 255]); diff --git a/crates/render/src/text_draw.rs b/crates/render/src/text_draw.rs new file mode 100644 index 000000000..3fe96d694 --- /dev/null +++ b/crates/render/src/text_draw.rs @@ -0,0 +1,320 @@ +use super::geometry::{blur, color, pre_translate, scale_of}; +use super::opts::ParaState; +use super::{ + Affine, Arc, BezPath, Ctx, FontDb, HashMap, Opts, PhType, Rect, RenderContext, Rgba, Shape, TextBody, Vec2, colr, paint, peniko, resolve, +}; + +/// The prompt shown in an empty placeholder, formatted like its content would be. +pub(crate) 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(deckcraft_model::text::Anchor::Bottom); + if let Some(p) = t.paragraphs.first_mut() { + p.props.align = Some(deckcraft_model::text::Align::Center); + p.props.bullet = Some(deckcraft_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()); +} + +pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc { + let key = (face.id(), gid); + if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) { + return p; + } + 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 `opts.rect`. +pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody, m: Affine, opts: &Opts) { + let l = deckcraft_text::layout(rctx, s, body, opts); + draw_layout(ctx, &l, opts.rect, m); +} + +/// Add transient list markers to an editor layout, using the document's own bullet glyphs. +pub(crate) fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) { + for mut run in l.empty_bullets.drain(..) { + run.color = Rgba::rgb(0xB3, 0xB3, 0xB3); + run.alpha = 1.0; + l.runs.push(run); + } +} + +/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local → +/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre. +/// +/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's +/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about +/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180° +/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this. +pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine { + let c = tr.center().to_vec2(); + let mut t = m; + if m.determinant() < 0.0 && c.is_finite() { + t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c); + } + if rotation != 0.0 && rotation.is_finite() && c.is_finite() { + t = t * Affine::translate(c) * Affine::rotate(rotation.to_radians()) * Affine::translate(-c); + } + t +} + +/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws. +pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option { + let bp = resolve::body(rctx, s); + let name = bp.warp.as_deref()?; + deckcraft_geom::warp::warp(name, &bp.warp_adj, tr) +} + +/// Draw an already laid-out text block. +pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) { + draw_layout_with(ctx, l, tr, m, &[]); +} + +/// Draw a laid-out text block with per-paragraph animation states. +pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) { + let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default(); + let m = text_transform(m, tr, l.rotation); + let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| { + let ps = para(d.para); + if !ps.visible || ps.opacity <= 0.001 { + return; + } + ctx.set_transform(m * Affine::translate(ps.offset)); + ctx.set_paint(color(d.color, ps.opacity)); + fill_deco(ctx, d); + }; + for d in l.decos.iter().filter(|d| d.behind) { + deco(ctx, d); + } + // Text shadows, then glows, under all the text. + for pass in [TextEffect::Shadow, TextEffect::Glow] { + for run in &l.runs { + let ps = para(run.para); + if ps.visible && ps.opacity > 0.001 { + text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity); + } + } + } + for run in &l.runs { + if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + continue; + } + let ps = para(run.para); + if !ps.visible || ps.opacity <= 0.001 { + continue; + } + let m = m * Affine::translate(ps.offset); + // A gradient fill starts over on every line and spans that line's text (from its top to + // its bottom, from its first to its last character), as in PowerPoint. + let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new(); + for (gid, x, y) in &run.glyphs { + let at = m * glyph_place(run, *gid, *x, *y); + let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else { + draw_glyph(ctx, run, *gid, at, ps.opacity); + continue; + }; + let li = line_at(l, run.para, *y); + if !grads.iter().any(|(i, _)| *i == li) { + let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] }; + let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect(); + let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr); + grads.push((li, paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity))); + } + let fill = grads.iter().find(|(i, _)| *i == li).map(|(_, g)| (g, Affine::translate((-*x, -*y)))); + draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill); + } + } + for d in l.decos.iter().filter(|d| !d.behind) { + deco(ctx, d); + } +} + +/// Where glyph `gid` of `run` with its pen at (`x`, `y`) is drawn, in text space: there, turned a +/// quarter turn back about its em box's centre when it stands upright in vertical text. +pub(crate) fn glyph_place(run: &deckcraft_text::GlyphRun, gid: u32, x: f64, y: f64) -> Affine { + let at = Affine::translate((x, y)); + match run.upright_centre(gid, x, y) { + Some((cx, cy)) => Affine::translate((cx, cy)) * Affine::rotate(-std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy)) * at, + None => at, + } +} + +/// Fill an underline, strikethrough or highlight with the current paint and transform: its box, +/// its dashes, or its zigzag stroked. +pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) { + if let Some((pts, w)) = d.wave() { + let mut path = BezPath::new(); + for (i, p) in pts.iter().enumerate() { + if i == 0 { + path.move_to(*p); + } else { + path.line_to(*p); + } + } + ctx.set_stroke(kurbo::Stroke::new(w.max(0.01))); + ctx.stroke_path(&path); + return; + } + for r in d.pieces() { + ctx.fill_rect(&r); + } +} + +/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline). +fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize { + if let Some(&(first, count)) = l.para_lines.get(para) { + if count == 0 { + return 0; + } + let end = (first + count).min(l.lines.len()); + if first < end { + return l.lines[first..end] + .iter() + .enumerate() + .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) + .map(|(i, _)| first + i) + .unwrap_or(0); + } + } + l.lines + .iter() + .enumerate() + .filter(|(_, li)| li.para == para) + .min_by(|a, b| (a.1.baseline - y).abs().total_cmp(&(b.1.baseline - y).abs())) + .map(|(i, _)| i) + .unwrap_or(0) +} + +/// The box a line's text covers: its top and bottom, its first and last caret. +pub(crate) fn line_bounds(li: &deckcraft_text::LineInfo) -> Option { + let (x0, x1) = li.caret_x.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| (a.min(*x), b.max(*x))); + let r = Rect::new(x0, li.top, x1, li.bottom); + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + +#[derive(Clone, Copy, PartialEq)] +enum TextEffect { + Shadow, + Glow, +} + +/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`. +fn text_effect(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, which: TextEffect, m: Affine, opacity: f64) { + let Some(fx) = run.effects else { return }; + let k = run.size / run.face.upem.max(1.0); + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let px = scale_of(m); + let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) { + (TextEffect::Shadow, Some(s), _) => (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0), + // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r + // past the glyph's edge and fades out by about two thirds of r. + (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68), + _ => return, + }; + let alpha = (c.a as f64 / 255.0) * opacity; + if alpha <= 0.0 { + return; + } + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma))); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + for (gid, x, y) in &run.glyphs { + let path = glyph(&run.face, *gid); + if path.elements().is_empty() { + continue; + } + // The offset is in slide space: a shadow falls the same way whatever the text's rotation. + let at = pre_translate(m * glyph_place(run, *gid, *x, *y), offset * px) * skew * Affine::scale(k); + ctx.set_transform(at); + ctx.fill_path(&path); + if grow > 0.0 { + ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + ctx.pop_layer(); +} + +/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`. +pub(crate) fn draw_glyph(ctx: &mut RenderContext, run: &deckcraft_text::GlyphRun, gid: u32, at: Affine, opacity: f64) { + draw_glyph_with(ctx, run, gid, at, opacity, None); +} + +/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from +/// the glyph's origin to that space) instead of the run's colour. +fn draw_glyph_with( + ctx: &mut RenderContext, + run: &deckcraft_text::GlyphRun, + gid: u32, + at: Affine, + opacity: f64, + fill: Option<(&peniko::Gradient, Affine)>, +) { + let alpha = run.alpha * opacity; + if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() { + return; + } + let k = run.size / run.face.upem.max(1.0); + // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still + // strokes the plain glyph below. + if alpha > 0.0 && run.outline.is_none() && run.face.is_color() && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha) { + return; + } + let path = glyph(&run.face, gid); + if path.elements().is_empty() { + return; + } + let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + ctx.set_transform(at * skew * Affine::scale(k)); + if let Some((g, to_text)) = fill + && alpha > 0.0 + { + ctx.set_paint(g.clone()); + ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text); + ctx.fill_path(&path); + ctx.reset_paint_transform(); + } else if alpha > 0.0 && run.color.a > 0 { + ctx.set_paint(color(run.color, alpha)); + 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, opacity)); + ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } +} diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs index ee760767d..ca2a37e1a 100644 --- a/crates/render/src/transition.rs +++ b/crates/render/src/transition.rs @@ -87,7 +87,7 @@ pub fn render_transition(pres: &Presentation, index: usize, t: f64, opts: &Rende .iter() .map(|x| crate::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) .collect(); - return render_blend(pres, from, index, m.mix, &m.shapes, &text, &paths, &o); + return render_blend(pres, (from, index), m.mix, &m.shapes, &text, &paths, &o); } } let new = render_slide(pres, index, opts); diff --git a/crates/text/src/autonum.rs b/crates/text/src/autonum.rs new file mode 100644 index 000000000..c705f5490 --- /dev/null +++ b/crates/text/src/autonum.rs @@ -0,0 +1,160 @@ +fn roman(mut n: u32, upper: bool) -> String { + let table = [ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ]; + let mut s = String::new(); + if n == 0 || n > 3999 { + return n.to_string(); + } + for (v, r) in table { + while n >= v { + s.push_str(r); + n -= v; + } + } + if upper { s.to_uppercase() } else { s } +} + +fn alpha_num(n: u32, upper: bool) -> String { + if n == 0 { + return String::new(); + } + let n = n - 1; + let c = (b'a' + (n % 26) as u8) as char; + let reps = (n / 26 + 1).min(50); + let s: String = std::iter::repeat_n(c, reps as usize).collect(); + if upper { s.to_uppercase() } else { s } +} + +/// Letters of a numbering alphabet: 1 → the first, past the end the letter doubles ("aa"), as +/// the Latin alphabetic schemes do. +fn letters(list: &[char], n: u32) -> String { + let len = list.len().max(1) as u32; + let Some(i) = n.checked_sub(1) else { return String::new() }; + let c = list.get((i % len) as usize).copied().unwrap_or('?'); + std::iter::repeat_n(c, (i / len + 1).min(50) as usize).collect() +} + +/// `n` written with the digits `zero..zero+9` (Thai, Devanagari, full-width digits). +fn digits_in(n: u32, zero: char) -> String { + n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| char::from_u32(zero as u32 + v)).unwrap_or(d)).collect() +} + +const CJK_DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + +/// Chinese numerals: 10 → 十, 11 → 十一, 25 → 二十五, 105 → 一百零五. +pub(crate) fn chinese_num(n: u32) -> String { + if n == 0 || n > 99_999 { + return n.to_string(); + } + let units = ['\0', '十', '百', '千', '万']; + let ds: Vec = n.to_string().chars().filter_map(|c| c.to_digit(10)).collect(); + let mut out = String::new(); + let mut zero = false; + for (i, d) in ds.iter().enumerate() { + let place = ds.len() - 1 - i; + if *d == 0 { + zero = true; + continue; + } + if zero { + out.push('零'); + zero = false; + } + // "十" alone for 10–19, not "一十". + if !(*d == 1 && place == 1 && i == 0) { + out.push(CJK_DIGITS.get(*d as usize).copied().unwrap_or('?')); + } + if let Some(u) = units.get(place).filter(|u| **u != '\0') { + out.push(*u); + } + } + out +} + +/// Text of an auto-number bullet: `arabicPeriod` 3 → "3.". The East Asian, Thai, Hindi, Arabic +/// and Hebrew schemes are written as PowerPoint draws them: `ea1ChsPeriod` 10 → "十.", +/// `ea1JpnKorPeriod` 10 → "一〇." (digit by digit), `arabicDbPeriod` 13 → "13.", +/// `thaiAlphaParenR` 4 → "ง)", `hindiAlpha1Period` 1 → "क.", `arabic2Minus` 10 → "ي-" +/// (abjad order), `circleNumWdWhitePlain` 13 → "③" (those circles go to ten). +pub fn autonum(scheme: &str, n: u32) -> String { + const THAI: [char; 41] = [ + 'ก', 'ข', 'ค', 'ง', 'จ', 'ฉ', 'ช', 'ซ', 'ฌ', 'ญ', 'ฎ', 'ฏ', 'ฐ', 'ฑ', 'ฒ', 'ณ', 'ด', 'ต', 'ถ', 'ท', 'ธ', 'น', 'บ', 'ป', 'ผ', 'ฝ', 'พ', 'ฟ', + 'ภ', 'ม', 'ย', 'ร', 'ล', 'ว', 'ศ', 'ษ', 'ส', 'ห', 'ฬ', 'อ', 'ฮ', + ]; + const HINDI_VOWELS: [char; 11] = ['अ', 'आ', 'इ', 'ई', 'उ', 'ऊ', 'ऋ', 'ए', 'ऐ', 'ओ', 'औ']; + const HINDI_CONSONANTS: [char; 33] = [ + 'क', 'ख', 'ग', 'घ', 'ङ', 'च', 'छ', 'ज', 'झ', 'ञ', 'ट', 'ठ', 'ड', 'ढ', 'ण', 'त', 'थ', 'द', 'ध', 'न', 'प', 'फ', 'ब', 'भ', 'म', 'य', 'र', 'ल', + 'व', 'श', 'ष', 'स', 'ह', + ]; + const ARABIC_ALPHABET: [char; 28] = + ['ا', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي']; + const ARABIC_ABJAD: [char; 28] = + ['ا', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ']; + const HEBREW: [char; 22] = ['א', 'ב', 'ג', 'ד', 'ה', 'ו', 'ז', 'ח', 'ט', 'י', 'כ', 'ל', 'מ', 'נ', 'ס', 'ע', 'פ', 'צ', 'ק', 'ר', 'ש', 'ת']; + let circled = |first: u32, count: u32| char::from_u32(first + n.saturating_sub(1) % count).map(String::from).unwrap_or_else(|| n.to_string()); + let core = if scheme.starts_with("romanUc") { + roman(n, true) + } else if scheme.starts_with("romanLc") { + roman(n, false) + } else if scheme.starts_with("alphaUc") { + alpha_num(n, true) + } else if scheme.starts_with("alphaLc") { + alpha_num(n, false) + } else if scheme.starts_with("circleNumWdWhite") { + return circled(0x2460, 10); + } else if scheme.starts_with("circleNumWdBlack") { + return circled(0x2776, 10); + } else if scheme.starts_with("circleNum") { + return char::from_u32(0x2460 + n.saturating_sub(1).min(19)).map(String::from).unwrap_or_else(|| n.to_string()); + } else if scheme.starts_with("ea1Chs") || scheme.starts_with("ea1Cht") { + chinese_num(n) + } else if scheme.starts_with("ea1Jpn") { + n.to_string().chars().map(|d| d.to_digit(10).and_then(|v| CJK_DIGITS.get(v as usize).copied()).unwrap_or(d)).collect() + } else if scheme.starts_with("arabicDb") { + digits_in(n, '0') + } else if scheme.starts_with("thaiAlpha") { + letters(&THAI, n) + } else if scheme.starts_with("thaiNum") { + digits_in(n, '๐') + } else if scheme.starts_with("hindiAlpha1") { + letters(&HINDI_CONSONANTS, n) + } else if scheme.starts_with("hindiAlpha") { + letters(&HINDI_VOWELS, n) + } else if scheme.starts_with("hindiNum") { + digits_in(n, '०') + } else if scheme.starts_with("arabic1") { + letters(&ARABIC_ALPHABET, n) + } else if scheme.starts_with("arabic2") { + letters(&ARABIC_ABJAD, n) + } else if scheme.starts_with("hebrew2") { + letters(&HEBREW, n) + } else { + n.to_string() + }; + // East Asian schemes end with a full-width full stop. + let wide = scheme.starts_with("ea1") || scheme.starts_with("arabicDb"); + if scheme.ends_with("ParenBoth") { + format!("({core})") + } else if scheme.ends_with("ParenR") { + format!("{core})") + } else if scheme.ends_with("Period") { + format!("{core}{}", if wide { '.' } else { '.' }) + } else if scheme.ends_with("Minus") { + format!("{core}-") + } else { + core + } +} diff --git a/crates/text/src/bidi.rs b/crates/text/src/bidi.rs index ac9b3afd9..cc8d0aacc 100644 --- a/crates/text/src/bidi.rs +++ b/crates/text/src/bidi.rs @@ -66,109 +66,16 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo if classes.iter().any(|c| matches!(c, LRE | RLE | LRO | RLO | PDF | LRI | RLI | FSI | PDI)) { return levels(text, chars, rtl); } - let n = chars.len(); - let rl = |i: usize| rtl_lang.get(i).copied().unwrap_or(false); - let sos = if rtl { Dir::R } else { Dir::L }; - // W1: combining marks take the class of the character they sit on. - let mut cls = classes; - for i in 0..n { - if cls.get(i) == Some(&NSM) { - let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; - if let Some(c) = cls.get_mut(i) { - *c = prev; - } - } - } - let mut dir = vec![Dir::Open; n]; - // Strong letters and digits. - let mut last_strong: Option = None; - for i in 0..n { - let c = cls.get(i).copied().unwrap_or(ON); - let d = match c { - L => Dir::L, - R | AL => Dir::R, - AN => Dir::Num, - EN if !rl(i) => Dir::L, - // W7: European digits after left-to-right text are left to right. - EN if last_strong == Some(L) => Dir::L, - EN => Dir::Num, - _ => Dir::Open, - }; - if matches!(c, L | R | AL) { - last_strong = Some(c); - } - if let Some(x) = dir.get_mut(i) { - *x = d; - } - } - // W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next - // to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, - // computed once so a long run of currency signs costs linear work, not a scan per sign. - let after_et = next_non_et(&cls); - for i in 0..n { - if !rl(i) || dir.get(i) != Some(&Dir::Open) { - continue; - } - let c = cls.get(i).copied().unwrap_or(ON); - let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); - let joined = match c { - ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), - ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), - _ => false, - }; - if joined && let Some(x) = dir.get_mut(i) { - *x = Dir::Num; - } - } + let cls = resolve_nsm(classes, rtl); + let mut dir = initial_dirs(&cls, rtl_lang); + join_numbers_in_rtl_runs(&mut dir, &cls, rtl_lang); // Punctuation in left-to-right runs is left to right. - for i in 0..n { - if !rl(i) - && dir.get(i) == Some(&Dir::Open) - && !matches!(cls.get(i), Some(WS | S)) - && let Some(x) = dir.get_mut(i) - { - *x = Dir::L; - } - } - // Neutrals in right-to-left runs: the direction around them when both sides agree, else - // right to left. Unresolved spaces of left-to-right runs are looked through. - let strong_at = |dir: &[Dir], j: usize| match dir.get(j) { - Some(Dir::L) => Some(Dir::L), - Some(Dir::R | Dir::Num) => Some(Dir::R), - _ => None, - }; - // Resolving a span only changes characters before the scan position, so the nearest strong - // direction after a span can be computed once up front, and the one before it is carried along: - // linear work however many neutral spans the paragraph has. - let mut next_strong: Vec> = vec![None; n + 1]; - for j in (0..n).rev() { - let here = strong_at(&dir, j).or(next_strong.get(j + 1).copied().flatten()); - if let Some(x) = next_strong.get_mut(j) { - *x = here; - } - } - let mut last_strong: Option = None; - let mut i = 0; - while i < n { - if !(rl(i) && dir.get(i) == Some(&Dir::Open)) { - last_strong = strong_at(&dir, i).or(last_strong); - i += 1; - continue; - } - let start = i; - while i < n && rl(i) && dir.get(i) == Some(&Dir::Open) { - i += 1; - } - let before = last_strong.unwrap_or(sos); - let after = next_strong.get(i).copied().flatten().unwrap_or(sos); - let d = if before == after { before } else { Dir::R }; - for x in dir.get_mut(start..i).into_iter().flatten() { - *x = d; - } - if start < i { - last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + for (i, d) in dir.iter_mut().enumerate() { + if !in_rtl_lang(rtl_lang, i) && *d == Dir::Open && !matches!(cls.get(i), Some(WS | S)) { + *d = Dir::L; } } + resolve_rtl_neutrals(&mut dir, rtl_lang, if rtl { Dir::R } else { Dir::L }); // Spaces in left-to-right runs follow the character before them. let mut prev = Dir::L; for d in dir.iter_mut() { @@ -187,6 +94,115 @@ pub(crate) fn levels_lang(text: &str, chars: &[char], rtl: bool, rtl_lang: &[boo .collect() } +fn in_rtl_lang(rtl_lang: &[bool], i: usize) -> bool { + rtl_lang.get(i).copied().unwrap_or(false) +} + +/// W1: combining marks take the class of the character they sit on. +fn resolve_nsm(mut cls: Vec, rtl: bool) -> Vec { + for i in 0..cls.len() { + if cls.get(i) == Some(&NSM) { + let prev = if i == 0 { if rtl { R } else { L } } else { cls.get(i - 1).copied().unwrap_or(L) }; + if let Some(c) = cls.get_mut(i) { + *c = prev; + } + } + } + cls +} + +/// Strong letters and digits get a direction; everything else stays open. +fn initial_dirs(cls: &[BidiClass], rtl_lang: &[bool]) -> Vec { + let mut last_strong: Option = None; + cls.iter() + .enumerate() + .map(|(i, &c)| { + let d = match c { + L => Dir::L, + R | AL => Dir::R, + AN => Dir::Num, + EN if !in_rtl_lang(rtl_lang, i) => Dir::L, + // W7: European digits after left-to-right text are left to right. + EN if last_strong == Some(L) => Dir::L, + EN => Dir::Num, + _ => Dir::Open, + }; + if matches!(c, L | R | AL) { + last_strong = Some(c); + } + d + }) + .collect() +} + +/// W4/W5 in right-to-left runs: a separator between digits, and currency or percent signs next +/// to them, belong to the number. `after_et[i]` is the first index after `i` that is not ET, +/// computed once so a long run of currency signs costs linear work, not a scan per sign. +fn join_numbers_in_rtl_runs(dir: &mut [Dir], cls: &[BidiClass], rtl_lang: &[bool]) { + let after_et = next_non_et(cls); + for i in 0..dir.len() { + if !in_rtl_lang(rtl_lang, i) || dir.get(i) != Some(&Dir::Open) { + continue; + } + let c = cls.get(i).copied().unwrap_or(ON); + let num = |j: Option| j.and_then(|j| dir.get(j)).is_some_and(|d| *d == Dir::Num); + let joined = match c { + ES | CS => num(i.checked_sub(1)) && num(Some(i + 1)), + ET => num(i.checked_sub(1)) || num(after_et.get(i).copied().flatten()), + _ => false, + }; + if joined && let Some(x) = dir.get_mut(i) { + *x = Dir::Num; + } + } +} + +fn strong_at(dir: &[Dir], j: usize) -> Option { + match dir.get(j) { + Some(Dir::L) => Some(Dir::L), + Some(Dir::R | Dir::Num) => Some(Dir::R), + _ => None, + } +} + +/// Neutrals in right-to-left runs: the direction around them when both sides agree, else right to +/// left. Unresolved spaces of left-to-right runs are looked through. +/// +/// Resolving a span only changes characters before the scan position, so the nearest strong +/// direction after a span can be computed once up front, and the one before it is carried along: +/// linear work however many neutral spans the paragraph has. +fn resolve_rtl_neutrals(dir: &mut [Dir], rtl_lang: &[bool], sos: Dir) { + let n = dir.len(); + let rl = |dir: &[Dir], i: usize| in_rtl_lang(rtl_lang, i) && dir.get(i) == Some(&Dir::Open); + let mut next_strong: Vec> = vec![None; n + 1]; + for j in (0..n).rev() { + let here = strong_at(dir, j).or(next_strong.get(j + 1).copied().flatten()); + if let Some(x) = next_strong.get_mut(j) { + *x = here; + } + } + let mut last_strong: Option = None; + let mut i = 0; + while i < n { + if !rl(dir, i) { + last_strong = strong_at(dir, i).or(last_strong); + i += 1; + continue; + } + let start = i; + while i < n && rl(dir, i) { + i += 1; + } + let before = last_strong.unwrap_or(sos); + let after = next_strong.get(i).copied().flatten().unwrap_or(sos); + let d = if before == after { before } else { Dir::R }; + for x in dir.get_mut(start..i).into_iter().flatten() { + *x = d; + } + last_strong = Some(if d == Dir::Num { Dir::R } else { d }); + } +} + /// For each index, the first later index whose class is not ET (`None` when only ET follows). fn next_non_et(cls: &[BidiClass]) -> Vec> { let mut out = vec![None; cls.len()]; diff --git a/crates/text/src/breaking.rs b/crates/text/src/breaking.rs new file mode 100644 index 000000000..36b9e5925 --- /dev/null +++ b/crates/text/src/breaking.rs @@ -0,0 +1,309 @@ +use super::style::{Cell, ParaShaped}; +use super::{FontFace, ParaProps}; + +pub(crate) fn measure_str(face: &FontFace, size: f64, s: &str) -> (f64, Vec<(u32, f64)>) { + let g = deckcraft_fonts::shape(face, s, &[], |c| c); + let k = size / face.upem.max(1.0); + let mut x = 0.0; + let mut out = vec![]; + for gl in g { + out.push((gl.gid, x + gl.x_offset as f64 * k)); + x += gl.x_advance as f64 * k; + } + (x, out) +} + +/// Single line spacing as a multiple of the font size. PowerPoint spaces single-spaced lines 1.2 +/// times the size apart in every font (observed on twenty Latin, CJK, Thai and Indic fonts at 18 +/// and 40 pt), whatever the font's own ascent and descent. +/// +/// Caveat: this was measured on one Windows install of desktop PowerPoint. It may be a Windows or +/// machine setting; PowerPoint on macOS or the web may space lines by the font's own metrics. +/// Keep it a single tunable value, and re-measure on the other platform before changing it. +pub(crate) const LINE_PITCH: f64 = 1.2; + +/// A line's height above and below the baseline at `size`: [`LINE_PITCH`] × size, split where +/// the font's Windows ascent and descent put the baseline. +pub(crate) fn line_metrics(face: &FontFace, size: f64) -> (f64, f64) { + let h = size * LINE_PITCH; + (h * face.line_split, h * (1.0 - face.line_split)) +} + +/// Hangul syllables and jamo: PowerPoint wraps Korean at word boundaries (spaces), not between +/// syllables. +fn is_hangul(c: char) -> bool { + matches!(c, '\u{1100}'..='\u{11FF}' | '\u{3130}'..='\u{318F}' | '\u{A960}'..='\u{A97F}' | '\u{AC00}'..='\u{D7FF}') +} + +/// East Asian commas and full stops, the punctuation PowerPoint lets hang past the end of a line +/// (`hangingPunct`). Closing brackets don't hang: "「。」" overflowing by its "」" moves to the next +/// line whole. +fn is_hanging_punct(c: char) -> bool { + matches!(c, '、' | '。' | ',' | '.' | '、' | '。') +} + +/// Small kana, the prolonged sound mark and iteration marks: PowerPoint keeps them off the start +/// of a line (strict Japanese line breaking), where UAX #14's default allows a break before them. +fn is_cj_nonstarter(c: char) -> bool { + matches!( + c, + 'ぁ' | 'ぃ' + | 'ぅ' + | 'ぇ' + | 'ぉ' + | 'っ' + | 'ゃ' + | 'ゅ' + | 'ょ' + | 'ゎ' + | 'ゕ' + | 'ゖ' + | 'ァ' + | 'ィ' + | 'ゥ' + | 'ェ' + | 'ォ' + | 'ッ' + | 'ャ' + | 'ュ' + | 'ョ' + | 'ヮ' + | 'ヵ' + | 'ヶ' + | 'ー' + | '々' + | '〻' + | 'ゝ' + | 'ゞ' + | 'ヽ' + | 'ヾ' + | '\u{31F0}'..='\u{31FF}' + | 'ァ'..='ー' + ) +} + +/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then +/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and +/// Korean breaks only between words. +pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec, Vec) { + let n = text.chars().count(); + let mut allowed = vec![false; n + 1]; + let mut mandatory = vec![false; n + 1]; + let mut byte_to_char = std::collections::HashMap::new(); + for (ci, (b, _)) in text.char_indices().enumerate() { + byte_to_char.insert(b, ci); + } + byte_to_char.insert(text.len(), n); + for (b, op) in unicode_linebreak::linebreaks(text) { + if let Some(&ci) = byte_to_char.get(&b) { + if let Some(a) = allowed.get_mut(ci) { + *a = true; + } + if op == unicode_linebreak::BreakOpportunity::Mandatory + && let Some(m) = mandatory.get_mut(ci) + { + *m = true; + } + } + } + let chars: Vec = text.chars().collect(); + for (i, w) in chars.windows(2).enumerate() { + let (prev, next) = (w[0], w[1]); + // PowerPoint never breaks after a zero-width space, nor before an em dash (only after it). + let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) { + false + } else { + latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() + }; + if !keep + && !mandatory.get(i + 1).copied().unwrap_or(false) + && let Some(a) = allowed.get_mut(i + 1) + { + *a = false; + } + } + (allowed, mandatory) +} + +pub(crate) struct RawLine { + pub(crate) start: usize, + pub(crate) end: usize, + /// End including trailing spaces / the break char consumed. + next: usize, +} + +/// Wrapping geometry of one paragraph: line widths, start offsets, wrap flag and tab settings. +pub(crate) struct BreakParams<'a> { + pub(crate) first_w: f64, + pub(crate) rest_w: f64, + pub(crate) wrap: bool, + pub(crate) tab_stops: &'a [Tab], + pub(crate) default_tab: f64, + pub(crate) x0_first: f64, + pub(crate) x0_rest: f64, +} + +pub(crate) fn break_lines(p: &ParaShaped, params: &BreakParams) -> Vec { + let BreakParams { first_w, rest_w, wrap, tab_stops, default_tab, x0_first, x0_rest } = *params; + let text: String = p.cells.iter().map(|c| c.ch).collect(); + let (allowed, _) = break_opportunities(&text, p.props.latin_line_break.unwrap_or(false)); + let hang = p.props.hanging_punct.unwrap_or(false); + let n = p.cells.len(); + let mut lines = vec![]; + let mut start = 0; + if n == 0 { + return vec![RawLine { start: 0, end: 0, next: 0 }]; + } + while start < n { + let avail = if lines.is_empty() { first_w } else { rest_w }; + let x0 = if lines.is_empty() { x0_first } else { x0_rest }; + let mut x = 0.0; + let mut last_break: Option = None; + let mut i = start; + let mut end = n; + let mut next = n; + while i < n { + let c = p.cells.get(i).map(|c| c.ch).unwrap_or(' '); + if c == '\u{b}' || c == '\n' { + end = i; + next = i + 1; + break; + } + let adv = if c == '\t' { + tab_advance(x0 + x, tab_stops, default_tab, tab_segment(&p.cells, i)) + } else { + p.cells.get(i).map(|c| c.adv).unwrap_or(0.0) + }; + let breakable = |j: usize| p.cells.get(j).is_none_or(|c| c.breakable(j)); + if i > start && allowed.get(i).copied().unwrap_or(false) && breakable(i) { + last_break = Some(i); + } + // Closing East Asian punctuation hangs past the end instead of wrapping + // (`hangingPunct`); the line breaks after it. + if wrap && hang && x + adv > avail + 0.01 && x <= avail + 0.01 && is_hanging_punct(c) && i > start { + end = i + 1; + next = i + 1; + break; + } + // A tab that would run past the end moves to the next line (and to its first stop there). + if wrap && c == '\t' && x + adv > avail + 0.01 && i > start { + end = i; + next = i; + while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') { + end -= 1; + } + break; + } + if wrap && x + adv > avail + 0.01 && !c.is_whitespace() && i > start { + // Break at the last opportunity, or force mid-word (between clusters). + let b = last_break + .filter(|b| *b > start) + .unwrap_or_else(|| (start + 1..=i).rev().find(|&j| breakable(j)).or_else(|| (i + 1..n).find(|&j| breakable(j))).unwrap_or(n)); + end = b; + next = b; + // Trailing spaces belong to the line but don't count. + while end > start && p.cells.get(end - 1).is_some_and(|c| c.ch == ' ') { + end -= 1; + } + break; + } + x += adv; + i += 1; + } + if i >= n { + end = n; + next = n; + } + lines.push(RawLine { start, end, next }); + if next <= start { + // No progress: force one char. + let last = lines.len() - 1; + let to = (start + 1..n).find(|&j| p.cells.get(j).is_none_or(|c| c.breakable(j))).unwrap_or(n); + if let Some(l) = lines.get_mut(last) { + l.end = to; + l.next = to; + } + start = to; + } else { + start = next; + } + if next == n && p.cells.last().is_some_and(|c| c.ch == '\u{b}') && start >= n { + // A trailing line break opens an empty last line. + lines.push(RawLine { start: n, end: n, next: n }); + } + } + lines +} + +/// How a tab stop aligns the text after it (`a:tab/@algn`). +#[derive(Clone, Copy, Debug, PartialEq)] +enum TabAlign { + Left, + Center, + Right, + /// The first "." of the text sits at the stop (text without one ends there). + Decimal, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Tab { + pos: f64, + align: TabAlign, +} + +pub(crate) fn tabs_of(props: &ParaProps) -> Vec { + props + .tabs + .as_ref() + .map(|t| { + t.iter() + .map(|s| Tab { + pos: s.pos, + align: match s.align.as_str() { + "ctr" => TabAlign::Center, + "r" => TabAlign::Right, + "dec" => TabAlign::Decimal, + _ => TabAlign::Left, + }, + }) + .collect() + }) + .unwrap_or_default() +} + +/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of +/// that text before its first ".". +pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { + let mut w = 0.0; + let mut dec = None; + for c in cells.iter().skip(i + 1) { + if matches!(c.ch, '\t'..='\u{b}') { + break; + } + if c.ch == '.' && dec.is_none() { + dec = Some(w); + } + w += c.adv; + } + (w, dec.unwrap_or(w)) +} + +/// Advance of a tab at `x` (from the start edge) to the next stop, placing the text after it +/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never +/// moving back, as PowerPoint does; past the last stop, to the next default stop. +pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 { + for s in stops { + if s.pos > x + 0.5 { + let to = s.pos - x; + return match s.align { + TabAlign::Left => to, + TabAlign::Right => (to - seg.0).max(0.0), + TabAlign::Center => (to - seg.0 / 2.0).max(0.0), + TabAlign::Decimal => (to - seg.1).max(0.0), + }; + } + } + let d = if default_tab > 1.0 { default_tab } else { 72.0 }; + let next = ((x / d).floor() + 1.0) * d; + (next - x).max(1.0) +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs new file mode 100644 index 000000000..f764cf52a --- /dev/null +++ b/crates/text/src/layout.rs @@ -0,0 +1,840 @@ +use super::breaking::{BreakParams, RawLine, Tab, break_lines, line_metrics, measure_str, tab_advance, tab_segment, tabs_of}; +use super::run::{Deco, DecoLine, GlyphRun}; +use super::shape::{SUB_SUPER, Upright, shape_para}; +use super::style::{Cell, Opts, ParaShaped, Style, spacing_pts}; +use super::{ + Align, Anchor, Arc, AutoFit, BodyProps, Bullet, Caps, Ctx, FontFace, LineInfo, Rect, Shape, Spacing, Strike, TextBody, TextDir, TextLayout, bidi, + joins_following, joins_preceding, resolve, +}; + +#[cfg(test)] +thread_local! { + /// How many times `layout_scaled` ran on this thread (tests count autofit's passes). + pub(crate) static LAYOUT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Lay out a shape's text body. +pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLayout { + let bp = resolve::body(ctx, shape); + let autofit = bp.autofit.unwrap_or(AutoFit::None); + // Shrink on overflow starts from the scale the file was saved with (`fontScale`, + // `lnSpcReduction`), which is what PowerPoint draws; editing the text resets it. + let (scale0, reduce0, no_saved_scale) = match (autofit, opts.no_shrink) { + (AutoFit::Shrink { font_scale, line_reduction }, false) => ( + font_scale.filter(|s| s.is_finite()).map(|s| s.clamp(0.1, 1.0)).unwrap_or(1.0), + if line_reduction.is_finite() { line_reduction.clamp(0.0, 0.2) } else { 0.0 }, + font_scale.is_none(), + ), + _ => (1.0, 0.0, false), + }; + let mut l = layout_scaled(ctx, shape, body, &bp, opts, scale0, reduce0); + // A scale PowerPoint saved is drawn as it is: PowerPoint fits the text again only when it + // changes (and editing resets the saved scale), not when a file opens, even where our + // metrics would call it overflowing (trailing empty paragraphs, substituted fonts). + // A body whose file has no `fontScale` at all (``) is the schema default + // 100 % and is drawn as it is too, not refitted on open (#724). + let saved = no_saved_scale || scale0 < 1.0 || reduce0 > 0.0; + if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink) + && l.overflow + && !saved + { + // Text that overflows even at the smallest scale gets that scale: every larger one + // overflows too, so searching would only lay it out a dozen more times (seconds for a + // body of tens of thousands of paragraphs, on every redraw of the slide). + let smallest = layout_scaled(ctx, shape, body, &bp, opts, 0.1, 0.2); + if smallest.overflow { + return smallest; + } + // Binary search the largest scale that fits (steps like 90%, 80%… with line reduction). + let (mut lo, mut hi) = (0.1_f64, scale0); + let mut best = Some(smallest); + for _ in 0..12 { + let mid = (lo + hi) / 2.0; + let red: f64 = ((1.0 - mid) * 0.8_f64).min(0.2).max(reduce0); + let t = layout_scaled(ctx, shape, body, &bp, opts, mid, red); + if t.overflow { + hi = mid; + } else { + lo = mid; + best = Some(t); + } + } + if let Some(b) = best { + l = b; + } + } + l +} + +/// Geometry of the text box shared by every line: rotation, inner rectangle, wrapping and columns. +struct BoxGeom { + vert: TextDir, + rotation: f64, + inner: Rect, + wrap: bool, + ncols: usize, + col_gap: f64, + col_w: f64, + rtl_cols: bool, +} + +impl BoxGeom { + fn new(shape: &Shape, bp: &BodyProps, text_rect: Rect) -> Self { + let vert = bp.vert.unwrap_or(TextDir::Horizontal); + let rotation = match vert { + TextDir::Vertical | TextDir::EaVertical | TextDir::Stacked | TextDir::StackedRtl | TextDir::MongolianVertical => 90.0, + TextDir::Vertical270 => 270.0, + TextDir::Horizontal => 0.0, + } + bp.rot.unwrap_or(0.0); + // For vertical text, lay out in a box with swapped dimensions centred on the text rect. + let r = if (rotation - 90.0).abs() < 1e-6 || (rotation - 270.0).abs() < 1e-6 { + let c = text_rect.center(); + Rect::new(c.x - text_rect.height() / 2.0, c.y - text_rect.width() / 2.0, c.x + text_rect.height() / 2.0, c.y + text_rect.width() / 2.0) + } else { + text_rect + }; + let inner = Rect::new( + r.x0 + bp.inset_l.unwrap_or(7.2), + r.y0 + bp.inset_t.unwrap_or(3.6), + (r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)), + (r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)), + ); + // Text that follows a WordArt arch or circle is set on one line per paragraph. + let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow); + let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true)); + let ncols = bp.columns.unwrap_or(1).clamp(1, 16) as usize; + let col_gap = bp.col_spacing.unwrap_or(0.0).max(0.0); + let col_w = ((inner.width() - col_gap * (ncols as f64 - 1.0)) / ncols as f64).max(1.0); + let rtl_cols = bp.rtl_col.unwrap_or(false) && ncols > 1; + BoxGeom { vert, rotation, inner, wrap, ncols, col_gap, col_w, rtl_cols } + } + + /// East Asian vertical text: CJK stands upright in text turned a quarter turn; stacked text + /// stands every character upright, one under the other. + fn upright(&self) -> Upright { + match self.vert { + TextDir::EaVertical => Upright::Cjk, + TextDir::Stacked | TextDir::StackedRtl => Upright::All, + _ => Upright::None, + } + } + + /// Mongolian vertical and stacked text: turned like vert, their lines run from left to right + /// (as PowerPoint draws both). + fn lines_ltr(&self) -> bool { + matches!(self.vert, TextDir::MongolianVertical | TextDir::Stacked) + } + + /// Where column `c` starts: from the left, or from the right with `rtlCol` (PowerPoint puts the + /// first column on the right). + fn col_x(&self, c: usize) -> f64 { + (if self.rtl_cols { self.ncols.saturating_sub(1 + c) } else { c }) as f64 * (self.col_w + self.col_gap) + } +} + +/// Number shown for a paragraph at list level `lvl`, advancing the per-level counters. +fn next_number(numbering: &mut [u32], lvl: usize, auto_num: bool, empty: bool) -> Option { + if auto_num && !empty { + if let Some(n) = numbering.get_mut(lvl) { + *n += 1; + } + for deeper in numbering.iter_mut().skip(lvl + 1) { + *deeper = 0; + } + numbering.get(lvl).copied() + } else if auto_num { + // Preview the next number without consuming it: empty paragraphs do not advance + // numbering in the saved document or affect later populated paragraphs. + numbering.get(lvl).map(|n| n.saturating_add(1)) + } else { + if !empty { + for n in numbering.iter_mut().skip(lvl) { + *n = 0; + } + } + None + } +} + +fn shape_paragraphs(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts, scale: f64, upright: Upright) -> Vec { + let mut numbering: Vec = vec![0; 9]; + let mut shaped_paras: Vec = Vec::with_capacity(body.paragraphs.len()); + for (pi, para) in body.paragraphs.iter().enumerate() { + let lvl = para.level.min(8) as usize; + let pp = resolve::para(ctx, shape, para); + let auto_num = matches!(pp.bullet, Some(Bullet::AutoNum { .. })); + let number = next_number(&mut numbering, lvl, auto_num, para.is_empty()); + shaped_paras.push(shape_para(ctx, shape, body, pi, scale, opts, number, upright)); + } + shaped_paras +} + +/// A laid-out line with its placement data and, on a paragraph's first line, its bullet. +struct PlacedLine { + info: LineInfo, + place: LinePlace, + bullet: Option<(String, f64, f64)>, + /// Width of the line's text plus its start offset and right margin. + content_w: f64, +} + +/// Vertical flow state while paragraphs are stacked. +struct Flow { + y: f64, + prev_after: f64, + /// How far the last line's full pitch reaches past the text (exact spacing). + tail: f64, + lines: Vec, +} + +/// Measurements shared by the lines of one paragraph. +struct ParaEnv<'a> { + p: &'a ParaShaped, + g: &'a BoxGeom, + reduce: f64, + margin: f64, + indent: f64, + mr: f64, + bullet_w: f64, + first_x: f64, + rest_x: f64, + tabs: &'a [Tab], +} + +impl ParaEnv<'_> { + fn start_x(&self, li: usize) -> f64 { + if li == 0 { self.first_x } else { self.rest_x } + } +} + +pub(crate) fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts: &Opts, scale: f64, reduce: f64) -> TextLayout { + #[cfg(test)] + LAYOUT_PASSES.with(|n| n.set(n.get() + 1)); + let g = BoxGeom::new(shape, bp, opts.rect); + let shaped_paras = shape_paragraphs(ctx, shape, body, opts, scale, g.upright()); + let mut out = TextLayout { inner: g.inner, font_scale: scale, line_reduction: reduce, rotation: g.rotation, ..Default::default() }; + let first_last = bp.space_first_last.unwrap_or(false); + let mut flow = Flow { y: 0.0, prev_after: 0.0, tail: 0.0, lines: vec![] }; + for (pi, p) in shaped_paras.iter().enumerate() { + place_paragraph(p, pi, &g, (reduce, first_last), &mut flow, &mut out); + } + // The text ends at the last line's descent under exact spacing; the last paragraph's space + // after counts only when the body says so. + let total = (flow.y - flow.tail).max(0.0) + if first_last { flow.prev_after } else { 0.0 }; + out.content_height = total; + let mut lines = flow.lines; + assign_columns(&mut lines, &g); + let used_h = if g.ncols > 1 { lines.iter().map(|l| l.info.bottom).fold(0.0, f64::max) } else { total }; + out.overflow = used_h > g.inner.height() + 0.5 || (g.ncols > 1 && lines.last().is_some_and(|l| l.info.bottom > g.inner.height() + 0.5)); + let (ox, oy) = text_origin(&lines, &g, bp, used_h); + // Emit runs, in visual order. + for mut pl in lines { + position_line(&mut pl.info, ox, oy, &g); + if let Some(p) = shaped_paras.get(pl.info.para) { + let to_empty = body.paragraphs.get(pl.info.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none(); + emit_bullet(&mut out, p, &pl, ox + g.col_x(pl.info.column), to_empty); + emit_line_runs(&mut out, p, &pl.info, &pl.place); + } + out.lines.push(pl.info); + } + out.boundaries = shaped_paras + .iter() + .map(|p| { + let mut b: Vec = p.cells.iter().map(|c| c.boundary).collect(); + b.push(true); + if let Some(f) = b.first_mut() { + *f = true; + } + b + }) + .collect(); + // Merge adjacent decorations of the same colour on the same line. + out.decos = merge_decos(std::mem::take(&mut out.decos)); + out +} + +/// Where the first line's text starts: past the bullet when there is one. +fn first_line_start(has_bullet: bool, margin: f64, indent: f64, bullet_w: f64) -> f64 { + if !has_bullet { + return (margin + indent).max(0.0); + } + let bx = (margin + indent).max(0.0); + // A hanging bullet's text starts at the margin. When the hang reaches left of the box + // (marL + indent < 0), PowerPoint pins the bullet to the box's edge and keeps the + // hang: the first line's text starts at -indent (later lines stay at the margin). + let stop = margin.max(-indent); + if indent < 0.0 && bx + bullet_w <= stop { stop } else { bx + bullet_w + if indent >= 0.0 { 0.0 } else { 3.0 } } +} + +/// Breaks one paragraph into lines and stacks them onto `flow`. `spacing` is +/// (line spacing reduction, whether the first paragraph's space before counts). +fn place_paragraph(p: &ParaShaped, pi: usize, g: &BoxGeom, spacing: (f64, bool), flow: &mut Flow, out: &mut TextLayout) { + let (reduce, first_last) = spacing; + let props = &p.props; + let margin = props.margin_left.unwrap_or(0.0).max(0.0); + let indent = props.indent.unwrap_or(0.0); + let mr = props.margin_right.unwrap_or(0.0).max(0.0); + let bullet_w = p.bullet.as_ref().map(|(s, st)| measure_str(&st.face, st.size, s).0).unwrap_or(0.0); + let first_x = first_line_start(p.bullet.is_some(), margin, indent, bullet_w); + let rest_x = margin; + let tabs = tabs_of(props); + let first_w = (g.col_w - first_x - mr).max(1.0); + let rest_w = (g.col_w - rest_x - mr).max(1.0); + let raw = break_lines( + p, + &BreakParams { + first_w, + rest_w, + wrap: g.wrap, + tab_stops: &tabs, + default_tab: props.default_tab.unwrap_or(72.0), + x0_first: first_x, + x0_rest: rest_x, + }, + ); + // The first paragraph's space before counts only when the body says so. + let before = if pi == 0 && !first_last { 0.0 } else { spacing_pts(props.space_before, p.lead_size) }; + flow.y += before.max(0.0) + flow.prev_after; + let first_line_idx = flow.lines.len(); + let env = ParaEnv { p, g, reduce, margin, indent, mr, bullet_w, first_x, rest_x, tabs: &tabs }; + for (li, rl) in raw.iter().enumerate() { + let is_last = li + 1 == raw.len(); + let placed = place_line(&env, pi, rl, li, is_last, flow); + out.content_width = out.content_width.max(placed.content_w); + flow.lines.push(placed); + } + out.para_lines.push((first_line_idx, raw.len())); + flow.prev_after = spacing_pts(props.space_after, p.lead_size).max(0.0); +} + +/// Max ascent / descent / size over the line's styles (or the lead style if empty). +fn line_extent(p: &ParaShaped, rl: &RawLine) -> (f64, f64, f64) { + let (mut asc, mut desc, mut size) = (0.0f64, 0.0f64, 0.0f64); + for c in p.cells.get(rl.start..rl.end.max(rl.start)).unwrap_or(&[]) { + if let (Some(st), Some(face)) = (p.styles.get(c.style), p.faces.get(c.face)) { + let sz = st.size; + // The run's own fonts set the line; a fallback face (emoji, a symbol or a + // script the run's fonts lack) does not move the baseline in PowerPoint. + let own = Arc::ptr_eq(face, &st.face) + || [st.ea.as_deref(), st.cs.as_deref(), st.sym.as_deref()].into_iter().flatten().any(|f| face.family.eq_ignore_ascii_case(f)); + let (a, d) = line_metrics(if own { face } else { &st.face }, sz); + asc = asc.max(a); + desc = desc.max(d); + size = size.max(sz); + } + } + if size == 0.0 { + // PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the + // `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not + // by the paragraph's first run (#780). An empty *paragraph* already lands on + // `end_props` through `lead`, and keeps the same height here. + let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style)); + let (face, sz) = match brk { + Some(st) => (&st.face, st.size), + None if rl.end >= p.cells.len() => (&p.end_face, p.end_size), + // Anything else empty (a line of tabs alone) keeps the old measure. + None => (&p.lead_face, p.lead_size), + }; + let (a, d) = line_metrics(face, sz); + return (a, d, sz); + } + (asc, desc, size) +} + +/// Logical advance of each cell on the line (tabs measure from the start edge) and their sum. +fn line_advances(env: &ParaEnv, rl: &RawLine, is_last: bool, x0: f64) -> (Vec, f64) { + let p = env.p; + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let n = rl.end.saturating_sub(rl.start); + let mut adv = Vec::with_capacity(n); + let mut x = 0.0; + // A line that breaks at a soft hyphen ends with a visible hyphen. + let shy_end = !is_last && line_cells.last().is_some_and(|c| c.ch == '\u{AD}'); + for (k, c) in line_cells.iter().enumerate() { + let a = if shy_end && k + 1 == n { + p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0) + } else if c.ch == '\t' { + tab_advance(x0 + x, env.tabs, p.props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k)) + } else { + c.adv + }; + adv.push(a); + x += a; + } + (adv, x) +} + +/// Justification: kashida (justLow, Arabic), spaces, or every character. Widens `adv` and returns +/// the kashida widths and whether the line was filled to the box edges. +fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, slack: f64, stretch: bool) -> (Vec, bool) { + let n = adv.len(); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let mut kashida = vec![0.0; n]; + let mut filled = false; + if stretch && align == Align::JustLow { + let ops = kashida_opportunities(p, rl.start, rl.end); + if !ops.is_empty() { + let each = slack / ops.len() as f64; + for k in ops { + if let (Some(e), Some(a)) = (kashida.get_mut(k), adv.get_mut(k)) { + *e = each; + *a += each; + } + } + filled = true; + } + } + if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) { + let spaces = line_cells.iter().filter(|c| c.ch == ' ').count(); + if spaces > 0 { + for (a, c) in adv.iter_mut().zip(line_cells) { + if c.ch == ' ' { + *a += slack / spaces as f64; + } + } + filled = true; + } + } + if align == Align::Distributed && n > 1 { + for a in adv.iter_mut().take(n - 1) { + *a += slack / (n - 1) as f64; + } + filled = true; + } + (kashida, filled) +} + +/// Left edge of the line's text inside its box `(box_l, box_r)`. +fn line_left(align: Align, filled: bool, rtl: bool, bx: (f64, f64), line_w: f64, slack: f64, n: usize) -> f64 { + let (box_l, box_r) = bx; + if filled { + return box_l; + } + match align { + Align::Left => box_l, + Align::Right => box_r - line_w, + Align::Center => box_l + (box_r - box_l - line_w) / 2.0, + Align::Distributed if n <= 1 => box_l + slack / 2.0, + // Justified lines that aren't stretched (the last one) sit at the start edge. + Align::Justify | Align::JustLow | Align::Distributed if rtl => box_r - line_w, + Align::Justify | Align::JustLow | Align::Distributed => box_l, + } +} + +/// Visual boxes of each cell and the caret x for every boundary, from the visual order and widths. +fn edges_and_carets(order: &[usize], levels: &[u8], adv: &[f64], left: f64, rtl: bool, line_w: f64) -> (Vec<(f64, f64)>, Vec) { + let n = adv.len(); + let mut edges = vec![(left, left); n]; + let mut vx = left; + for &k in order { + let a = adv.get(k).copied().unwrap_or(0.0); + if let Some(e) = edges.get_mut(k) { + *e = (vx, vx + a); + } + vx += a; + } + let odd = |k: usize| levels.get(k).is_some_and(|l| l % 2 == 1); + let mut caret_x: Vec = (0..n).map(|k| edges.get(k).map(|e| if odd(k) { e.1 } else { e.0 }).unwrap_or(left)).collect(); + caret_x.push(match n.checked_sub(1) { + Some(k) => edges.get(k).map(|e| if odd(k) { e.0 } else { e.1 }).unwrap_or(left), + None if rtl => left + line_w, + None => left, + }); + (edges, caret_x) +} + +/// Bullet text, x and baseline for a paragraph's first line. +fn line_bullet(env: &ParaEnv, align: Align, content_shift: f64, baseline: f64) -> Option<(String, f64, f64)> { + let p = env.p; + p.bullet.as_ref().map(|(s, _)| { + // Centred and end-aligned text carries its bullet along. + let end_aligned = if p.rtl { align == Align::Left } else { align == Align::Right }; + let shifted = align == Align::Center || end_aligned; + let b = if shifted && p.cells.is_empty() { + content_shift + env.first_x - env.bullet_w + } else { + (env.margin + env.indent).max(0.0) + if shifted { content_shift } else { 0.0 } + }; + // x_bullet = S - (marL + indent) for RTL: the bullet's start edge is its right. + let bx = if p.rtl { env.g.col_w - b - env.bullet_w } else { b }; + (s.clone(), bx, baseline) + }) +} + +/// Lays out line `li` (`rl`) of a paragraph at the flow's current height and advances the flow. +fn place_line(env: &ParaEnv, pi: usize, rl: &RawLine, li: usize, is_last: bool, flow: &mut Flow) -> PlacedLine { + let (p, g) = (env.p, env.g); + let props = &p.props; + let (asc, desc, size) = line_extent(p, rl); + let natural = (asc + desc).max(size * 1.0); + let lh = match props.line_spacing { + // The reduction comes off the percentage (90 % less 20 % is 70 %), as PowerPoint + // applies `lnSpcReduction`. + Some(Spacing::Pct(f)) => natural * (f - env.reduce).max(f * 0.1).max(0.0), + Some(Spacing::Pts(v)) => v * (1.0 - env.reduce), + None => natural * (1.0 - env.reduce), + }; + let ratio = if natural > 0.0 { lh / natural } else { 1.0 }; + let top = flow.y; + // Exact spacing (`spcPts`), as PowerPoint sets it: the baseline sits at 80 % of the + // line pitch, and the text ends at the last line's descent rather than a full pitch. + let exact = matches!(props.line_spacing, Some(Spacing::Pts(_))); + let baseline = if exact { top + lh * 0.8 } else { top + asc * ratio }; + flow.tail = if exact { (lh * 0.2 - desc).max(-lh) } else { 0.0 }; + flow.y += lh; + let x0 = env.start_x(li); + let avail = (g.col_w - x0 - env.mr).max(0.0); + let n = rl.end.saturating_sub(rl.start); + let line_cells = p.cells.get(rl.start..rl.end).unwrap_or(&[]); + let (mut adv, width) = line_advances(env, rl, is_last, x0); + let align = props.align.unwrap_or(Align::Left); + let slack = (avail - width).max(0.0); + let stretch = !is_last && g.wrap && slack > 0.0; + let (kashida, filled) = justify_line(p, rl, &mut adv, align, slack, stretch); + let line_w: f64 = adv.iter().sum(); + // The line box, in column coordinates: the start edge is the left one for LTR and the + // right one (S = W - rIns) for RTL paragraphs. + let (box_l, box_r) = if p.rtl { (env.mr, g.col_w - x0) } else { (x0, g.col_w - env.mr) }; + let left = line_left(align, filled, p.rtl, (box_l, box_r), line_w, slack, n); + // Distance from the box's start edge to the text (bullets move with centred or + // end-aligned text). + let content_shift = if p.rtl { box_r - (left + line_w) } else { left - box_l }; + // Rules L1 + L2: this line's visual order. + let chars: Vec = line_cells.iter().map(|c| c.ch).collect(); + let raw_levels: Vec = line_cells.iter().map(|c| c.level).collect(); + let levels = bidi::line_levels(&chars, &raw_levels, u8::from(p.rtl)); + let order = bidi::visual_order(&levels); + let (edges, caret_x) = edges_and_carets(&order, &levels, &adv, left, p.rtl, line_w); + let bullet = if li == 0 { line_bullet(env, align, content_shift, baseline) } else { None }; + let boundary: Vec = line_cells.iter().map(|c| c.boundary).collect(); + PlacedLine { + info: LineInfo { + para: pi, + start: rl.start, + end: rl.end, + top, + baseline, + bottom: flow.y, + caret_x, + column: 0, + rtl: p.rtl, + levels, + edges, + boundary, + extent: (left, left + line_w), + start_offset: x0, + }, + place: LinePlace { order, kashida }, + bullet, + content_w: width + x0 + env.mr, + } +} + +/// Columns: distribute lines by height. +fn assign_columns(lines: &mut [PlacedLine], g: &BoxGeom) { + let col_h = if g.ncols > 1 { g.inner.height() } else { f64::INFINITY }; + let mut col = 0usize; + let mut col_y0 = 0.0; + for pl in lines.iter_mut() { + let li = &mut pl.info; + if g.ncols > 1 && li.bottom - col_y0 > col_h + 0.01 && col + 1 < g.ncols { + col += 1; + col_y0 = li.top; + } + li.column = col; + if col > 0 { + let dy = col_y0; + li.top -= dy; + li.baseline -= dy; + li.bottom -= dy; + } + let cx = g.col_x(col); + if cx != 0.0 { + li.shift_x(cx); + } + } +} + +/// Origin (x, y) of the text block inside the box, from the anchor and `anchorCtr`. +fn text_origin(lines: &[PlacedLine], g: &BoxGeom, bp: &BodyProps, used_h: f64) -> (f64, f64) { + let inner = g.inner; + let dy = match bp.anchor.unwrap_or(Anchor::Top) { + Anchor::Top => 0.0, + Anchor::Middle => (inner.height() - used_h) / 2.0, + // PowerPoint draws justified and distributed anchoring at the bottom. + Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h, + }; + // anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put + // them, so the origin is set so the block's centre lands on the box's centre — a left-edge + // offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink + // and stay out of the block. Overflowing content centres symmetrically past both edges. + // Not applied to vertical text (there the offset would run along the wrong axis) or + // multi-column bodies (centring unimplemented for them). + let centre_block = bp.anchor_ctr.unwrap_or(false) && g.ncols == 1 && matches!(g.vert, TextDir::Horizontal); + let ox = if centre_block && !lines.is_empty() { + let ink = lines.iter().map(|l| &l.info).filter(|li| li.end > li.start); + let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1))); + if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 } + } else { + inner.x0 + }; + (ox, inner.y0 + dy) +} + +/// Moves a line from column coordinates into shape-local ones. +fn position_line(line: &mut LineInfo, ox: f64, oy: f64, g: &BoxGeom) { + line.top += oy; + line.baseline += oy; + line.bottom += oy; + line.shift_x(ox); + if g.lines_ltr() { + // The line's place mirrored across the box: the first line ends up on the left. + let d = g.inner.y0 + g.inner.y1 - line.top - line.bottom; + line.top += d; + line.baseline += d; + line.bottom += d; + } +} + +/// Emits the bullet glyph run of a line's paragraph, if the line carries one. `shift_x` is the +/// text block's x origin plus the line's column offset. +fn emit_bullet(out: &mut TextLayout, p: &ParaShaped, pl: &PlacedLine, shift_x: f64, to_empty: bool) { + let line = &pl.info; + let Some((s, bx, _)) = pl.bullet.as_ref() else { return }; + let Some((_, st)) = p.bullet.as_ref() else { return }; + let (_, g) = measure_str(&st.face, st.size, s); + let run = GlyphRun { + face: st.face.clone(), + size: st.size, + color: st.color, + glyphs: g.into_iter().map(|(gid, x)| (gid, shift_x + bx + x, line.baseline)).collect(), + cells: vec![], + fake_bold: false, + fake_italic: false, + outline: None, + para: line.para, + chars: (0, 0), + link: false, + alpha: st.alpha, + gradient: None, + effects: None, + upright: false, + }; + if to_empty { + out.empty_bullets.push(run); + } else { + out.runs.push(run); + } +} + +/// Position of one cell on its line: index in the line, in the paragraph, its box, and the glyph +/// baseline and size. +struct CellPos { + k: usize, + ci: usize, + cx: f64, + cx1: f64, + base_y: f64, + size: f64, +} + +/// Starts a glyph run for `cell` drawn in style `st`. +fn new_run(cell: &Cell, st: &Style, face: &Arc, para: usize, pos: &CellPos) -> GlyphRun { + GlyphRun { + face: face.clone(), + size: pos.size, + color: st.color, + glyphs: vec![], + cells: vec![], + fake_bold: st.fake_bold, + fake_italic: st.fake_italic, + outline: st.outline, + para, + chars: (pos.ci, pos.ci + 1), + link: st.link, + alpha: st.alpha, + gradient: st.gradient.clone(), + effects: st.effects, + upright: cell.upright, + } +} + +/// Emits one line's glyph runs and decorations, in visual order. +fn emit_line_runs(out: &mut TextLayout, p: &ParaShaped, line: &LineInfo, place: &LinePlace) { + // Group visually consecutive cells by (style, face). + let mut cur: Option = None; + let mut cur_key = (usize::MAX, usize::MAX, false, false); + for &k in &place.order { + let ci = line.start + k; + let Some(cell) = p.cells.get(ci) else { continue }; + let Some(st) = p.styles.get(cell.style) else { continue }; + let Some(&(cx, cx1)) = line.edges.get(k) else { continue }; + let base_y = line.baseline - st.baseline * st.size; + let size = if st.baseline != 0.0 { st.size * SUB_SUPER } else { st.size }; + let small = st.caps == Caps::Small && cell.ch.is_lowercase(); + let size = if small { size * 0.8 } else { size }; + let pos = CellPos { k, ci, cx, cx1, base_y, size }; + // Small capitals are drawn smaller than the capitals beside them: a run of their own. + let key = (cell.style, cell.face, small, cell.upright); + if key != cur_key { + if let Some(r) = cur.take() { + out.runs.push(r); + } + let Some(face) = p.faces.get(cell.face) else { continue }; + cur = Some(new_run(cell, st, face, line.para, &pos)); + cur_key = key; + } + if let Some(r) = cur.as_mut() { + push_cell_glyphs(r, p, line, place, cell, &pos); + } + if cx1 - cx > 0.0 { + push_cell_decos(out, cell, st, line, &pos); + } + } + if let Some(r) = cur.take() { + out.runs.push(r); + } +} + +/// Appends the glyphs a cell draws to the run `r`. +fn push_cell_glyphs(r: &mut GlyphRun, p: &ParaShaped, line: &LineInfo, place: &LinePlace, cell: &Cell, pos: &CellPos) { + let CellPos { k, ci, cx, cx1, base_y, .. } = *pos; + r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1)); + if cell.ch == '\u{AD}' && cx1 - cx > 0.01 { + // The soft hyphen a line breaks at is drawn as a hyphen. + if let Some(f) = p.faces.get(cell.face) { + r.glyphs.push((f.glyph_for('-'), cx, base_y)); + r.cells.push((ci, cell.ch)); + } + } else if cell.head == ci && !cell.glyphs.is_empty() { + // The cluster's glyphs start at its leftmost cell; kashida (right-to-left + // only) fills the gap on the cluster's left with tatweel glyphs. + let cells_of = (k..line.end.saturating_sub(line.start)).take_while(|&j| p.cells.get(line.start + j).is_some_and(|c| c.head == ci)); + let (mut cl, mut kash) = (cx, 0.0); + for j in cells_of { + cl = cl.min(line.edges.get(j).map(|e| e.0).unwrap_or(cx)); + kash += place.kashida.get(j).copied().unwrap_or(0.0); + } + if kash > 0.0 { + push_tatweel(r, p.faces.get(cell.face), cell, (cl, kash), pos); + } + for (gid, gx, gy) in &cell.glyphs { + r.glyphs.push((*gid, cl + kash + gx, base_y + gy)); + r.cells.push((ci, cell.ch)); + } + } +} + +/// Fills `kash` points left of a cluster at `cl` with tatweel glyphs. +fn push_tatweel(r: &mut GlyphRun, face: Option<&Arc>, cell: &Cell, fill: (f64, f64), pos: &CellPos) { + let (cl, kash) = fill; + let tg = face.map(|f| f.glyph_for('\u{0640}')).unwrap_or(0); + let tw = face.map(|f| f.advance(tg) * pos.size / f.upem.max(1.0)).unwrap_or(0.0); + if tg != 0 && tw > 0.0 { + let count = ((kash / tw).ceil() as usize).clamp(1, 256); + for i in 0..count { + r.glyphs.push((tg, cl + (i as f64 * tw).min((kash - tw).max(0.0)), pos.base_y)); + r.cells.push((pos.ci, cell.ch)); + } + } +} + +/// Highlight, underline and strikethrough boxes of a cell. +fn push_cell_decos(out: &mut TextLayout, cell: &Cell, st: &Style, line: &LineInfo, pos: &CellPos) { + let (cx, cx1) = (pos.cx, pos.cx1); + if let Some(hl) = st.highlight { + out.decos.push(Deco { rect: Rect::new(cx, line.top, cx1, line.bottom), color: hl, behind: true, para: line.para, line: DecoLine::Solid }); + } + if cell.ch.is_whitespace() && st.underline.is_none() { + return; + } + if let Some((uc, u)) = st.underline { + // At the underline position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let (p, t) = (st.face.underline.0 * k, (st.face.underline.1 * k).max(0.5)); + for (y0, y1, kind) in u.boxes(p, t) { + out.decos.push(Deco { + rect: Rect::new(cx, line.baseline + y0, cx1, line.baseline + y1), + color: uc, + behind: false, + para: line.para, + line: kind, + }); + } + } + if let Some(sk) = st.strike { + // At the strikeout position and thickness of the run's own font. + let k = st.size / st.face.upem.max(1.0); + let t = (st.face.strikeout.1 * k).max(0.5); + let sy = line.baseline - st.face.strikeout.0 * k; + out.decos.push(Deco { rect: Rect::new(cx, sy, cx1, sy + t), color: st.color, behind: false, para: line.para, line: DecoLine::Solid }); + if sk == Strike::Double { + out.decos.push(Deco { + rect: Rect::new(cx, sy - t * 2.0, cx1, sy - t), + color: st.color, + behind: false, + para: line.para, + line: DecoLine::Solid, + }); + } + } +} + +fn merge_decos(v: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(v.len()); + for d in v { + // Lines drawn together (a double underline, a strikethrough) interleave: look a few back. + if let Some(last) = out.iter_mut().rev().take(4).find(|last| { + last.color == d.color + && last.behind == d.behind + && last.line == d.line + && (last.rect.y0 - d.rect.y0).abs() < 0.01 + && (last.rect.y1 - d.rect.y1).abs() < 0.01 + && (last.rect.x1 - d.rect.x0).abs() < 0.5 + }) { + last.rect.x1 = d.rect.x1; + continue; + } + out.push(d); + } + out +} + +/// How a line's cells are placed: visual order (indices from the line start) and kashida widths +/// (the boxes, with justification, are in [`LineInfo::edges`]). +struct LinePlace { + order: Vec, + kashida: Vec, +} + +/// Kashida opportunities on a line (indices from the line start), at most one per word (its last +/// join, where elongation reads most naturally): the last cell of a right-to-left Arabic grapheme +/// that joins the next letter, in a face with a tatweel glyph, when the two aren't one ligature. +fn kashida_opportunities(p: &ParaShaped, start: usize, end: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut word_has = false; + for i in start..end { + let Some(c) = p.cells.get(i) else { continue }; + if c.ch.is_whitespace() { + word_has = false; + continue; + } + if !c.boundary || c.level % 2 == 0 || !joins_following(c.ch) { + continue; + } + // The next grapheme on the line. + let Some(nx) = (i + 1..end).find(|&j| p.cells.get(j).is_some_and(|d| d.boundary)) else { continue }; + let Some(d) = p.cells.get(nx) else { continue }; + let same_cluster = d.head == c.head || d.head != nx; + let tatweel = p.faces.get(c.face).is_some_and(|f| f.glyph_for('\u{0640}') != 0); + if joins_preceding(d.ch) && d.face == c.face && !same_cluster && tatweel { + // A later join in the same word replaces the earlier one. + if word_has { + out.pop(); + } + out.push(nx - 1 - start); + word_has = true; + } + } + out +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e70c33046..9e03a2b4d 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -34,230 +34,33 @@ use deckcraft_model::text::{Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Par use deckcraft_model::{Fill, Shape}; use unicode_segmentation::UnicodeSegmentation; -/// 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)>, - /// Per glyph: the paragraph character it draws (index, char); empty for bullets. - pub cells: Vec<(usize, char)>, - /// 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, - /// Opacity of the fill on top of `color`'s own alpha (0 for no fill). - pub alpha: f64, - pub gradient: Option, - /// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`). - pub effects: Option, - /// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn - /// back about the centre of its em box ([`GlyphRun::upright_centre`]) inside text that is - /// laid out turned a quarter turn. - pub upright: bool, -} +mod autonum; +mod breaking; +mod layout; +mod run; +mod shape; +mod style; +mod symbol; -/// Share of the size the ideographic em box's centre sits above the baseline (CJK fonts put the -/// em box from 0.88 em above to 0.12 em below it). -pub const UPRIGHT_CENTRE: f64 = 0.38; - -impl GlyphRun { - /// The point an upright glyph at pen position (`x`, `y`) turns about: the centre of its em box, - /// `advance` wide (`None` when the run is not upright). - pub fn upright_centre(&self, gid: u32, x: f64, y: f64) -> Option<(f64, f64)> { - if !self.upright { - return None; - } - let adv = self.face.advance(gid) * self.size / self.face.upem.max(1.0); - Some((x + adv / 2.0, y - self.size * UPRIGHT_CENTRE)) - } -} - -/// A text run's shadow and glow, colours resolved. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RunEffects { - pub shadow: Option, - /// Glow colour (with its alpha) and radius in points. - pub glow: Option<(Rgba, f64)>, -} - -/// An outer shadow: colour (with its alpha), blur radius and offset in points. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TextShadow { - pub color: Rgba, - pub blur: f64, - pub dx: f64, - pub dy: f64, -} - -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, - /// Paragraph the decoration belongs to (by-paragraph animation). - pub para: usize, - /// How the line fills `rect`. - pub line: DecoLine, -} - -/// How an underline fills its box. -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub enum DecoLine { - /// The whole box. - #[default] - Solid, - /// Dashes or dots across the box's height, from its left end. - Dashed(Dash), - /// A zigzag between the box's top and bottom: `stroke` wide, one wave every `period`. - Wave { stroke: f64, period: f64 }, -} - -/// Dash patterns of the dotted and dashed underlines, in units of the line's thickness. Measured -/// on PowerPoint's export: dots and their gaps as long as the line is thick, dashes three -/// thicknesses long with gaps of two. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Dash { - Dot, - Dash, - Long, - DotDash, - DotDotDash, -} - -impl Dash { - /// On, off, on, off… lengths in thicknesses. - fn pattern(self) -> &'static [f64] { - match self { - Dash::Dot => &[1.0, 1.0], - Dash::Dash => &[3.0, 2.0], - Dash::Long => &[6.0, 2.0], - Dash::DotDash => &[3.0, 2.0, 1.0, 2.0], - Dash::DotDotDash => &[3.0, 2.0, 1.0, 2.0, 1.0, 2.0], - } - } -} - -impl Deco { - /// The filled boxes of a solid or dashed line (dashes start at the box's left end). A wavy - /// line gives a thin box along its middle, for drawing that can't stroke a path. - pub fn pieces(&self) -> Vec { - let r = self.rect; - match self.line { - DecoLine::Solid => vec![r], - DecoLine::Wave { stroke, .. } => { - let c = (r.y0 + r.y1) / 2.0; - vec![Rect::new(r.x0, c - stroke / 2.0, r.x1, c + stroke / 2.0)] - } - DecoLine::Dashed(d) => { - let unit = r.height().max(0.1); - let pat = d.pattern(); - let mut out = Vec::new(); - let mut x = r.x0; - let mut k = 0usize; - while x < r.x1 && out.len() < 10_000 { - let len = pat.get(k % pat.len()).copied().unwrap_or(1.0) * unit; - if k.is_multiple_of(2) { - out.push(Rect::new(x, r.y0, (x + len).min(r.x1), r.y1)); - } - x += len; - k += 1; - } - out - } - } - } - - /// The zigzag of a wavy underline, left to right from the box's top, and its stroke width; - /// None for other lines. - pub fn wave(&self) -> Option<(Vec, f64)> { - let DecoLine::Wave { stroke, period } = self.line else { return None }; - let r = self.rect; - let half = (period / 2.0).max(0.1); - let n = ((r.width().max(0.0) / half).ceil() as usize).min(20_000); - let pts = (0..=n) - .map(|i| { - let x = (r.x0 + i as f64 * half).min(r.x1); - Point::new(x, if i % 2 == 0 { r.y0 } else { r.y1 }) - }) - .collect(); - Some((pts, stroke)) - } -} - -/// An underline style (`a:rPr/@u`). -#[derive(Clone, Copy, Debug, PartialEq)] -enum Underline { - Single, - Double, - Heavy, - /// Dotted or dashed, heavy or not. - Dashed(Dash, bool), - /// Wavy, heavy or not. - Wavy(bool), -} - -impl Underline { - fn from_xml(u: &str) -> Option { - Some(match u { - "none" => return None, - // PowerPoint underlines the spaces of a "words" underline too. - "words" => Underline::Single, - // At text sizes PowerPoint's double wave reads as two straight lines. - "dbl" | "wavyDbl" => Underline::Double, - "heavy" => Underline::Heavy, - "dotted" => Underline::Dashed(Dash::Dot, false), - "dottedHeavy" => Underline::Dashed(Dash::Dot, true), - "dash" => Underline::Dashed(Dash::Dash, false), - "dashHeavy" => Underline::Dashed(Dash::Dash, true), - "dashLong" => Underline::Dashed(Dash::Long, false), - "dashLongHeavy" => Underline::Dashed(Dash::Long, true), - "dotDash" => Underline::Dashed(Dash::DotDash, false), - "dotDashHeavy" => Underline::Dashed(Dash::DotDash, true), - "dotDotDash" => Underline::Dashed(Dash::DotDotDash, false), - "dotDotDashHeavy" => Underline::Dashed(Dash::DotDotDash, true), - "wavy" => Underline::Wavy(false), - "wavyHeavy" => Underline::Wavy(true), - _ => Underline::Single, - }) - } - - /// The boxes of this underline below the baseline (top, bottom, fill), from the font's - /// underline position `p` and thickness `t` at the run's size. Measured on PowerPoint's export - /// (Arial 96 pt: p = 10.2, t = 7.0): single at p..p+t; heavy from p-t to p+0.55t; double as two - /// lines 0.55t thick spanning p-t..p+t; wavy a thin zigzag from p-0.6t to p+1.1t, a wave every - /// 3.9t. - fn boxes(self, p: f64, t: f64) -> Vec<(f64, f64, DecoLine)> { - let heavy = (p - t, p + 0.55 * t); - match self { - Underline::Single => vec![(p, p + t, DecoLine::Solid)], - Underline::Heavy => vec![(heavy.0, heavy.1, DecoLine::Solid)], - Underline::Double => vec![(p - t, p - 0.45 * t, DecoLine::Solid), (p + 0.45 * t, p + t, DecoLine::Solid)], - Underline::Dashed(d, false) => vec![(p, p + t, DecoLine::Dashed(d))], - Underline::Dashed(d, true) => vec![(heavy.0, heavy.1, DecoLine::Dashed(d))], - Underline::Wavy(h) => { - let stroke = (t * 0.15).max(0.4) * if h { 2.0 } else { 1.0 }; - vec![(p - 0.6 * t, p + 1.1 * t, DecoLine::Wave { stroke, period: t * 3.9 })] - } - } - } -} +pub use autonum::autonum; +#[cfg(test)] +use autonum::chinese_num; +#[cfg(test)] +use breaking::{LINE_PITCH, break_opportunities, measure_str}; +#[cfg(test)] +pub(crate) use layout::LAYOUT_PASSES; +pub use layout::layout; +#[cfg(test)] +use layout::layout_scaled; +pub use run::{Dash, Deco, DecoLine, GlyphRun, RunEffects, TextShadow, UPRIGHT_CENTRE}; +#[cfg(test)] +use shape::{SUB_SUPER, caps_char, is_upright, shaping_features}; +#[cfg(test)] +use style::Cell; +pub use style::{Fields, NoFields, Opts}; +#[cfg(test)] +use symbol::{covers_symbol, is_symbol_font, symbol_font_char, symbol_font_draw, symbol_unicode, symbol_unicode_alts}; +pub use symbol::{map_symbol_bullet, symbol_char}; #[derive(Clone, Debug, Default)] pub struct LineInfo { @@ -316,2108 +119,6 @@ pub struct TextLayout { pub boundaries: Vec>, } -/// Values for fields (`slidenum`, `datetime…`, `footer`). `None` keeps the field's saved text. -pub trait Fields { - fn field(&self, kind: &str) -> Option; - /// The value of field `kind` in a run of language `lang` (BCP 47, e.g. `en-US`): dates are - /// written the language's way. - fn field_in(&self, kind: &str, lang: Option<&str>) -> Option { - let _ = lang; - self.field(kind) - } -} - -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<(Rgba, Underline)>, - strike: Option, - highlight: Option, - outline: Option<(Rgba, f64)>, - link: bool, - gradient: Option, - effects: Option, - /// "Regular", "Bold"… for the slot and fallback faces. - style_name: &'static str, - /// BCP 47 language (`a:rPr/@lang`), for shaping. - lang: Option, - /// The `a:ea` and `a:cs` families (theme references resolved), if the document names one. - ea: Option, - cs: Option, - /// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font - /// characters) are drawn in it. - sym: Option, - /// Heading font (`+mj-…`): the theme's major font set applies. - major: bool, - /// Pair kerning on: `a:rPr/@kern` is set and the size reaches it. - kern: bool, -} - -/// One character of a paragraph after shaping. -#[derive(Clone)] -struct Cell { - ch: char, - style: usize, - face: usize, - /// Resolved bidi embedding level (before the line rules). - level: u8, - /// Glyphs of the shaping cluster this cell heads, from the cluster's visual left edge (gid, - /// x, y). Empty for the other cells of a cluster. - glyphs: Vec<(u32, f64, f64)>, - /// This character's advance: its share of the cluster (ligature carets); 0 inside a grapheme. - adv: f64, - /// Index of the cell heading this character's shaping cluster. - head: usize, - /// A grapheme starts here: carets stand and lines break only before such cells. - boundary: bool, - /// Drawn upright in East Asian vertical text. - upright: bool, -} - -impl Cell { - /// A line may start before this cell: a grapheme that starts a shaping cluster. - fn breakable(&self, i: usize) -> bool { - self.boundary && self.head == i - } -} - -struct ParaShaped { - cells: Vec, - styles: Vec