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/ci.yml b/.github/workflows/ci.yml index d0d1fd873..65fc735bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index d6b897cec..70a310d38 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,4 +27,4 @@ jobs: # 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/freebsd.yml@main with: - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 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/release.yml b/.github/workflows/release.yml index f65960f27..622ace75c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: with: app-name: designcraft app-title: DesignCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 85856800b..211ed988d 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,8 +26,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: designcraft app-dir: DesignCraft + install-relative-path: Programs/ArtCraft/DesignCraft + install-scope: perUser diff --git a/ASSETS.md b/ASSETS.md index 407a26c92..356309cee 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `crates/ui-egui/src/i18n/uk.rs` (332 common phrases) | VectorCraft contributors | https://github.com/storytold/vectorcraft/blob/01f329aa99fa0c9517311c7b6cf7233ffc207742/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted for DesignCraft context; remaining Ukrainian phrases are original or credited below | | `crates/ui-egui/src/i18n/uk.rs` (68 common phrases) | PhotoCraft contributors | https://github.com/storytold/photocraft/blob/ed057bdc6fd89a858c5e0ea4eab158a2a7052dde/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | | `crates/ui-egui/src/i18n/uk.rs` (37 common phrases) | WordCraft contributors | https://github.com/storytold/wordcraft/blob/7fb7b013591e2593fbd038f90bbc535be318e5a3/crates/ui-egui/src/i18n/uk.tsv | MIT OR Apache-2.0 | Adapted interface vocabulary | diff --git a/CLAUDE.md b/CLAUDE.md index e8c455b0a..bed556afb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ People trust DesignCraft with their layouts; a crash loses their work. **This ou - **Bound recursion** (seen-sets or depth limits for nested or cyclic documents). **Locks:** `lock().unwrap_or_else(PoisonError::into_inner)` or an error; thread joins are `Result`s. - **Last-resort guard:** escaped panics during command execution and file import/export become an error, never a lost document. It's a safety net, not a licence. - **Prove it:** every crash fix lands with a small synthetic regression test that panicked before the fix. -- Clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`). +- Clean crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new production crate roots must include the attribute before modules or other crate items, with crate-level `//!` documentation allowed before it. Every new workspace crate must also include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Tests may `unwrap` (`clippy.toml`). ## Non-negotiables - **Clean-room.** InDesign is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic documents, take screenshots (by window id) stored only under `plan/indesign/screenshots/` (never committed). Never read, disassemble or copy anything inside the InDesign bundle (names/listings only), never copy Adobe icons, artwork, presets or wording beyond feature names, never commit files produced by InDesign. Never copy GPL/AGPL/LGPL code (Scribus, LibreOffice, Ghostscript…). diff --git a/Cargo.lock b/Cargo.lock index beb1f6f8f..1c361aef2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,76 +4,70 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", - "objc2 0.5.2", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -212,30 +207,31 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" dependencies = [ "derive_arbitrary", ] [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -250,6 +246,9 @@ name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] [[package]] name = "as-raw-xcb-connection" @@ -353,13 +352,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -394,7 +393,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -446,12 +445,48 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -470,6 +505,15 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] + [[package]] name = "bit-vec" version = "0.8.0" @@ -482,6 +526,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -494,13 +548,22 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -509,7 +572,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -525,6 +588,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "brotli" version = "8.0.4" @@ -569,7 +656,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -600,14 +687,13 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", "rustix 1.1.5", - "slab", "tracing", ] @@ -629,7 +715,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop 0.14.4", + "calloop 0.14.5", "rustix 1.1.5", "wayland-backend", "wayland-client", @@ -652,9 +738,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.5.1" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -683,24 +769,82 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + +[[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" @@ -739,9 +883,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -756,6 +900,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -766,6 +916,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -779,7 +939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -792,7 +952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -802,12 +962,18 @@ version = "20.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" dependencies = [ - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "foreign-types", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -817,10 +983,28 @@ dependencies = [ "libm", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "craft-atomic" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "rustix 1.1.5", ] @@ -828,20 +1012,73 @@ dependencies = [ [[package]] name = "craft-cms" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "rayon", ] +[[package]] +name = "craft-dialogs" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1a11a4897865f7410c40d25ca6e452ce399a1a2c#1a11a4897865f7410c40d25ca6e452ce399a1a2c" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "rfd", +] + [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[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-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-i18n", + "craft-ui", + "egui", + "serde", + "serde_json", +] [[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 = [ "bytemuck", "hayro", @@ -851,32 +1088,118 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-prepress" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-cms", + "craft-pdf", + "kurbo", + "linesweeper", + "thiserror 2.0.21", + "ttf-parser", +] + [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +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", + "serde", + "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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.1", +] + +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", - "serde", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -917,6 +1240,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -930,21 +1263,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "designcraft" -version = "0.12.0" +version = "0.14.0" dependencies = [ "arboard", + "craft-update", + "craft-window", "designcraft-engine", "designcraft-ui-egui", "eframe", @@ -952,7 +1317,7 @@ dependencies = [ "fmv-macos-events", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "rfd", "serde", @@ -964,7 +1329,7 @@ dependencies = [ [[package]] name = "designcraft-cli" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -980,7 +1345,7 @@ dependencies = [ [[package]] name = "designcraft-color" -version = "0.12.0" +version = "0.14.0" dependencies = [ "craft-cms", "kurbo", @@ -992,7 +1357,7 @@ dependencies = [ [[package]] name = "designcraft-compose" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-color", "designcraft-doc", @@ -1012,7 +1377,7 @@ dependencies = [ [[package]] name = "designcraft-doc" -version = "0.12.0" +version = "0.14.0" dependencies = [ "chrono", "designcraft-color", @@ -1025,11 +1390,12 @@ dependencies = [ [[package]] name = "designcraft-engine" -version = "0.12.0" +version = "0.14.0" dependencies = [ "caseless", "craft-atomic", "craft-docpath", + "craft-prepress", "craft-print", "designcraft-color", "designcraft-compose", @@ -1051,7 +1417,7 @@ dependencies = [ "indd", "kurbo", "log", - "quick-xml", + "quick-xml 0.41.0", "same-file", "serde", "serde_json", @@ -1059,22 +1425,22 @@ dependencies = [ "thiserror 2.0.21", "unicode-segmentation", "wasm-bindgen-test", - "zip", + "zip 3.0.0", ] [[package]] name = "designcraft-epub" -version = "0.12.0" +version = "0.14.0" dependencies = [ "chrono", "designcraft-color", "designcraft-doc", - "zip", + "zip 3.0.0", ] [[package]] name = "designcraft-fonts" -version = "0.12.0" +version = "0.14.0" dependencies = [ "core-text", "harfrust 0.13.3", @@ -1087,19 +1453,19 @@ dependencies = [ [[package]] name = "designcraft-format" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-doc", "designcraft-images", "serde", "serde_json", "thiserror 2.0.21", - "zip", + "zip 3.0.0", ] [[package]] name = "designcraft-geom" -version = "0.12.0" +version = "0.14.0" dependencies = [ "flo_curves", "kurbo", @@ -1112,7 +1478,7 @@ dependencies = [ [[package]] name = "designcraft-idml" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1120,20 +1486,22 @@ dependencies = [ "designcraft-geom", "designcraft-images", "log", - "quick-xml", + "quick-xml 0.41.0", "thiserror 2.0.21", - "zip", + "zip 3.0.0", ] [[package]] name = "designcraft-images" -version = "0.12.0" +version = "0.14.0" dependencies = [ "craft-cms", "designcraft-color", "designcraft-fonts", + "flate2", "image", "log", + "quick-xml 0.41.0", "resvg", "tiff", "zune-core", @@ -1142,7 +1510,7 @@ dependencies = [ [[package]] name = "designcraft-mcp" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-engine", "designcraft-geom", @@ -1155,10 +1523,12 @@ dependencies = [ [[package]] name = "designcraft-pdf" -version = "0.12.0" +version = "0.14.0" dependencies = [ "chrono", + "craft-cms", "craft-pdf", + "craft-prepress", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1178,7 +1548,7 @@ dependencies = [ [[package]] name = "designcraft-pptx" -version = "0.12.0" +version = "0.14.0" dependencies = [ "chrono", "designcraft-color", @@ -1188,15 +1558,15 @@ dependencies = [ "designcraft-geom", "designcraft-images", "image", - "quick-xml", + "quick-xml 0.41.0", "skrifa 0.47.0", "thiserror 2.0.21", - "zip", + "zip 3.0.0", ] [[package]] name = "designcraft-render" -version = "0.12.0" +version = "0.14.0" dependencies = [ "craft-pdf", "designcraft-color", @@ -1215,16 +1585,16 @@ dependencies = [ [[package]] name = "designcraft-textimport" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-doc", - "quick-xml", - "zip", + "quick-xml 0.41.0", + "zip 3.0.0", ] [[package]] name = "designcraft-tools" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-color", "designcraft-compose", @@ -1236,9 +1606,14 @@ dependencies = [ [[package]] name = "designcraft-ui-egui" -version = "0.12.0" +version = "0.14.0" dependencies = [ + "craft-dialogs", + "craft-keymap", + "craft-prepress", "craft-ui", + "craft-update", + "craft-window", "designcraft-color", "designcraft-compose", "designcraft-doc", @@ -1251,19 +1626,19 @@ dependencies = [ "egui", "egui_kittest", "log", + "raw-window-handle", "serde", "serde_json", "tempfile", "toml", "unicode-bidi", - "unicode-bidi-mirroring", - "unicode-segmentation", - "zip", + "web-time", + "zip 3.0.0", ] [[package]] name = "designcraft-web" -version = "0.12.0" +version = "0.14.0" dependencies = [ "designcraft-engine", "designcraft-format", @@ -1293,6 +1668,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1308,7 +1693,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1319,7 +1704,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1353,10 +1738,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1364,9 +1754,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1380,7 +1769,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1388,6 +1777,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1399,9 +1789,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1419,9 +1808,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1439,31 +1827,34 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1475,9 +1866,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1496,19 +1886,32 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1538,16 +1941,15 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.5", + "font-types 0.12.7", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1556,22 +1958,23 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1657,10 +2060,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] -name = "find-msvc-tools" -version = "0.1.14" +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" @@ -1694,13 +2130,35 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fmv-macos-events" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -1711,6 +2169,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1728,9 +2192,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1776,7 +2240,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1794,6 +2258,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1827,9 +2307,15 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1843,12 +2329,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1868,6 +2367,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1887,8 +2399,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1919,7 +2434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1929,22 +2444,6 @@ dependencies = [ "vello_common 0.0.9", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1952,7 +2451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1988,7 +2487,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2063,6 +2562,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -2100,6 +2618,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2108,7 +2635,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2117,13 +2644,13 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bytemuck", "hayro-interpret", @@ -2143,7 +2670,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" 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 = [ "brotli", "hayro-postscript", @@ -2152,14 +2679,14 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "bitflags 2.13.2", "hayro-cmap", "hayro-syntax", "kurbo", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -2170,7 +2697,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2188,13 +2715,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "flate2", "hayro-ccitt", @@ -2224,6 +2750,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -2236,12 +2768,137 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "hypher" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45f2b4f6b5da34d400d4c112eadcc14512cc5d04307a8eee182586d9bebd7ce6" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2256,6 +2913,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" @@ -2264,11 +2938,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" @@ -2280,6 +2975,9 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -2289,6 +2987,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +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" @@ -2318,6 +3042,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2325,6 +3052,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" @@ -2396,6 +3147,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -2547,20 +3304,20 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[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", + "base64 0.22.1", "bumpalo", "flate2", "float-cmp", @@ -2620,9 +3377,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2649,7 +3406,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2658,6 +3415,19 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "linesweeper" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" +dependencies = [ + "arrayvec", + "kurbo", + "polycool", + "rustc-hash 2.1.3", + "smallvec", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2675,12 +3445,15 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2697,6 +3470,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "memchr" version = "2.8.3" @@ -2705,9 +3484,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -2721,6 +3500,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minicov" version = "0.3.8" @@ -2751,6 +3536,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2763,23 +3559,28 @@ dependencies = [ [[package]] name = "muda" -version = "0.21.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c" +checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "once_cell", "png", "thiserror 2.0.21", "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2848,12 +3649,33 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2863,6 +3685,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2903,9 +3731,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -2913,9 +3741,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -2929,7 +3757,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", @@ -2944,7 +3772,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -2958,7 +3786,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -2970,7 +3798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2982,7 +3810,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2994,7 +3822,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3005,7 +3833,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3017,7 +3845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3029,7 +3857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", ] @@ -3050,7 +3878,7 @@ dependencies = [ "block2 0.5.1", "dispatch", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -3061,7 +3889,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3072,7 +3900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3083,7 +3911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -3096,7 +3924,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3108,7 +3936,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3120,7 +3948,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3132,7 +3960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3145,7 +3973,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3157,7 +3985,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", @@ -3177,7 +4005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3189,7 +4017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3201,7 +4029,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -3220,14 +4048,20 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3249,9 +4083,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -3266,6 +4100,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -3283,7 +4127,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -3331,6 +4175,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3350,14 +4213,35 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3368,7 +4252,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3377,8 +4271,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3393,6 +4300,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pic-scale" version = "0.7.12" @@ -3458,6 +4374,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3521,23 +4450,32 @@ 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", ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "powerfmt" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "presser" @@ -3554,6 +4492,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3584,16 +4546,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", "rand", - "rand_chacha", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3634,6 +4597,72 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3657,40 +4686,43 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", ] [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3711,7 +4743,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -3754,7 +4786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -3765,7 +4797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -3776,7 +4808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -3800,9 +4832,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3842,6 +4874,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.47.0" @@ -3870,7 +4945,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3895,6 +4970,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "roots" version = "0.0.8" @@ -3909,9 +4998,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -3963,6 +5052,83 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -4005,6 +5171,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -4014,6 +5186,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4026,6 +5207,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4037,6 +5241,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -4065,14 +5279,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4081,6 +5295,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -4089,18 +5314,52 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -4117,6 +5376,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4201,9 +5629,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -4240,7 +5668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.13.2", - "calloop 0.14.4", + "calloop 0.14.5", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", @@ -4280,6 +5708,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4289,6 +5727,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4321,6 +5769,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4333,6 +5802,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.16.1" @@ -4343,6 +5818,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4356,15 +5841,24 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -4373,7 +5867,51 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", ] [[package]] @@ -4435,7 +5973,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4461,6 +5999,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -4489,11 +6057,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4504,19 +6073,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] -name = "tokio" -version = "1.53.1" +name = "tls_codec" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -4529,18 +6148,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -4550,18 +6169,63 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" @@ -4595,6 +6259,23 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4613,6 +6294,18 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "uds_windows" version = "1.2.1" @@ -4656,15 +6349,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -4699,6 +6392,47 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4717,7 +6451,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -4725,7 +6459,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "rustybuzz", "simplecss", "siphasher", @@ -4739,6 +6473,18 @@ dependencies = [ "xmlwriter", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4747,9 +6493,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4790,21 +6536,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -4834,18 +6565,6 @@ dependencies = [ "vello_common 0.0.8", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -4863,6 +6582,16 @@ dependencies = [ "vello_common 0.2.0", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -4888,6 +6617,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -4940,7 +6684,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4983,7 +6727,7 @@ checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5111,7 +6855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5149,20 +6893,29 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5297,7 +7050,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -5444,6 +7197,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", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5468,7 +7232,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5477,16 +7241,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5504,31 +7259,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5546,100 +7284,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" -source = "git+https://github.com/storytold/winit.git?rev=3d51077fd1a966f84df9ef287839dc84fd5b4ca7#3d51077fd1a966f84df9ef287839dc84fd5b4ca7" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5650,7 +7340,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5658,7 +7348,7 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", @@ -5696,10 +7386,41 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + +[[package]] +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -5717,6 +7438,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "write-fonts" version = "0.48.1" @@ -5730,11 +7469,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" @@ -5768,6 +7516,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -5793,6 +7576,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -5813,7 +7602,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.12.0" +version = "0.14.0" dependencies = [ "serde_json", ] @@ -5826,9 +7615,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5843,7 +7632,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -5915,7 +7704,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -5923,9 +7712,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -5955,18 +7744,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -5990,10 +7779,30 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -6001,16 +7810,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -6018,13 +7831,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6041,6 +7854,23 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6055,9 +7885,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -6082,9 +7912,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -6097,14 +7927,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -6117,6 +7947,26 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 0245708f0..139d06f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"] [workspace.package] -version = "0.12.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -12,7 +12,12 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", 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 } +# Shared keyboard bindings: chords, overrides, conflicts, egui dispatch, the Keyboard Shortcuts dialog. +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui", "ui"] } +# Separations, ink coverage and the ink manager; same revision as craft-pdf and the krilla patch. +craft-prepress = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["flatten"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -75,32 +80,41 @@ zip = { version = "3", default-features = false, features = ["deflate"] } indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } # Which paths stored in documents may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Atomic file replacement for saves and exports (craft-libs). -craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } +craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # File › Print through the system print spooler (craft-libs). -craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } +craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # ICC colour management: working spaces, transforms, black point compensation (craft-libs). -craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } +craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } +# The application bar as the window's title bar (craft-libs). +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" +# Monotonic clock on native and web (`Instant`); screenshot settlement must not use wall time. +web-time = "1" rfd = "0.17" +# Native file and message dialogs that never block the event loop (craft-libs). +craft-dialogs = { git = "https://github.com/storytold/craft-libs", rev = "1a11a4897865f7410c40d25ca6e452ce399a1a2c" } +raw-window-handle = "0.6" arboard = { version = "3.6", default-features = false } [workspace.lints.rust] 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" # Line tables keep file and line in panics and backtraces; full debug info @@ -122,10 +136,27 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } -winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" } -hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } +# The hayro crates from craft-libs' vendor/ directory (pdfcraft's, plus the plate mode and decode +# limits craft-pdf and craft-prepress need); same revision as craft-pdf. +hayro = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.3.0" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +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" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 71abff776..fc46bcdf3 100644 --- a/NOTICE +++ b/NOTICE @@ -114,3 +114,21 @@ Assets already bundled through Hayro's default features retain their licences: non-visual character-mapping data; see licenses/pdf-backend/hayro-cmap/LICENSE.txt. Adobe and contributor names do not endorse or promote this product. Per-file hashes and sources are recorded in ASSETS.md. + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index af3f0abff..6d27866cc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,9 +1,10 @@ # DesignCraft roadmap **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) +- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md). - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. @@ -11,6 +12,24 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +- **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that + starts on a floating panel over the bar moves the panel, not the window, and in full screen the + bar no longer zooms or moves the window. No readiness percentage change. +- **2026-10-24** · Embedded IDML keeps independent placed-PDF layer choices in table-cell and + footnote text. The variant rewrite reaches endnote stories too, but IDML endnote structures and + artwork remain unsupported. External-only export continues to reject active nested choices. + Shared placement ids with different sources or choices fail closed; no readiness percentage change. +- **2026-10-24** · PDF/A-2b preparation applies placed-PDF layer choices, including explicit all-on, + to anchors inside table-cell text, footnotes and endnotes in an export-only copy. The nested + IDML variant write pass remains separate work; no readiness percentage change. +- **2026-10-24** · Explicit PDF layer choices, including all-on, are checked with one actual + rewrite per distinct source before editing; reset to file defaults bypasses the check. Native + format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change. +- **2026-10-23** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults. + Legacy commands keep their reset behavior; native saves require a capable reader only for + the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change. +- **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees, + preserving native-edited layer order through round trips. Synthetic regression added. - **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported all-content crops retain the whole page rather than silently clipping hidden layers. @@ -19,6 +38,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope regression tests cover metadata, output-intent references, nonzero generations and forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change. +- **2026-10-23** · PDF layer commands reject overrides on layerless files atomically; + default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover + IDML export after rejection and both selection orders. + ## Status (2026-10-01) **Working today** @@ -59,7 +82,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Control panel scale X/Y, rotation and shear fields (`transform.info`, absolute `transform.set {scaleX, scaleY, rotation, shear}` about the reference point) and Preferences › Transformations are Totals for nested objects and placed graphics. - Variable fonts: each named instance is a style (Font menu, shaping with feature variations, metrics, outlines) and embeds at its axis settings in PDF; free axis sliders still to come. - Object ▸ Select (first/next above, next/last below, container, content, previous/next in group), full Object ▸ Convert Shape (`object.convertShape`), Table ▸ Split Cell Horizontally/Vertically. -- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML. +- Attributes panel (`object.attributes`: Overprint Fill/Stroke/Gap, Nonprinting), with Overprint Gap in Overprint Preview and IDML; PDF export writes them as `/op` and `/OP` with `/OPM 1`. - Page Numbering View (Preferences: section or absolute): page labels everywhere follow it; Go to Page takes section names, numbers or `+n`. - Preferences › Advanced Type (document `advancedType`: superscript/subscript size and position; IDML TextPreference); subscripts now drop 33.3% like InDesign. - Preferences › Composition › Highlight: keep violations, H&J violations (three shades), custom tracking/kerning, substituted fonts. @@ -71,12 +94,12 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Endnotes (`endnote.insert/edit/delete/list/options`, Type ▸ Insert Endnote): references number through the document, the endnote frame on a new last page lists them under a heading; IDML keeps the listed text only. - Editorial notes (`note.new/edit/delete/convertToText/list`, Notes panel): anchored in text, flagged on screen, never printed or exported. - File ▸ Print Booklet (`file.printBooklet`): saddle-stitch or 2-up consecutive printer spreads as PDF, padded with blanks, with a gap between pages. -- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs give pages with transparency a DeviceCMYK or DeviceRGB page group, so viewers and RIPs blend in that space. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colours keep to its gamut, as when composited in CMYK). +- Edit ▸ Transparency Blend Space (`edit.transparencyBlendSpace`; RGB for web/mobile documents; IDML TransparencyPreference) — exported PDFs blend in that space: CMYK print exports write their transparency groups and page groups in DeviceCMYK (PDF/X-4 also carries the CMYK working space as its `GTS_PDFX` output intent); RGB exports give pages with transparency a DeviceRGB page group. On screen, spreads with transparency in a CMYK blend space are shown through the working CMYK profile (their colors keep to its gamut, as when composited in CMYK). - High Contrast interface theme (fifth Interface Color Theme: black chrome, white text, yellow selection). - Place Excel workbooks (.xlsx): the first worksheet's used range becomes a table (shared/inline strings, numbers, booleans). - Object Library (`library.new/open/add/place/remove/list/json/close`, Library panel): `.dclib` files of snippets that keep stories, styles, swatches and images. - Ink Manager (`ink.list`, `ink.options`; Swatches panel menu): All Spots to Process, per-ink conversion and ink aliases in PDF output; IDML ConvertToProcess/AliasInkName. -- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack). +- Appearance of Black complete: Printing/Exporting rich black for RGB output (PNG), Overprint [Black] Swatch at 100% (document, IDML OverprintBlack; 100% [Black] fills, strokes and text overprint in PDF export). - Hyperlinks and Bookmarks panels (Window ▸ Interactive): new from the selection, edit (`hyperlink.edit`), go to source (`hyperlink.goToSource`), rename bookmarks (`bookmark.rename`), go to page, delete. - Nested line styles (`nestedLineStyles` paragraph attribute, Paragraph Style Options › Drop Caps and Nested Styles, IDML AllNestedLineStyles): the first lines restyle until the line breaks settle. - User dictionary hyphenation exceptions (`hyphenation.addException/removeException/list`, Edit ▸ Spelling ▸ User Dictionary): `ex~am~ple` breaks only at `~`, a plain word never hyphenates. @@ -92,7 +115,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Image Import Options › Crop to for placed PDFs (`file.place {pdfCrop: crop|art|trim|bleed|media|contentVisible|contentAll}`, Place PDF dialog): the graphic spans that page box, the document stores the choice, and IDML keeps it as `PDFCrop`. - Stroke styles: striped (thick-thin… and custom bands), wavy and straight-hash strokes now draw on screen and in PDF; named custom stripe/dash/dot styles (`strokeStyle.new/delete/list`, Stroke panel Type list; IDML Striped/Dashed/DottedStrokeStyle). - Object ▸ Primary Text Frame (`object.primaryTextFrame`): any text frame's story can become (or stop being) the primary story Smart Text Reflow follows. -- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame, inside the insets and between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. +- Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. - Object ▸ Clipping Path (`object.clippingPath {type: alpha|edges, threshold, tolerance}`): traces the placed image and makes the outline the frame. @@ -103,8 +126,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Content Collector and Placer tools (B; `conveyor.collect/place/list/clear`): copies of objects (with stories, styles, images) ride a conveyor between pages and documents; Alt-click keeps them on it. - Linked stories (Edit ▸ Place and Link, `story.placeAndLink/links/updateLink/unlink`): child stories copy a parent, show out of date in the Links panel and update. - Edit ▸ Menus (`window.hideMenuItem`): hide menu items; menus with hidden items end in Show All Menu Items. -- View ▸ Separations Preview (`view.separations`): one process plate as ink density, or areas over an ink limit in red (a GCR preview of the rendered view; spot plates and the flattener preview aren't there). -- Separations Preview plates now come from each object's own colour (CMYK as authored; RGB and images through the colour settings, images via a cached 17³ table), so pure black shows only on the black plate. +- View ▸ Separations Preview (`view.separations`, Window ▸ Output ▸ Separations Preview, `ink.separations`): the plates the print PDF puts on film, spot plates included. Each visible spread is exported as Export PDF writes it and separated with craft-prepress, so the Ink Manager's aliases and spots to process, overprint and the flattener show as printed. All inks as printed, one plate as film, or an ink limit in red over gray; the panel lists the plates with each ink's percentage under the pointer. - Language (Character panel ▸ Language, Properties ▸ Character, `type.char {language}`): text in other languages isn't hyphenated or spell-checked with the English dictionaries, typographer's quotes follow the language (German „…“, French and Russian «…», Swiss, Spanish, Dutch, Nordic, Japanese and Traditional Chinese 「…」, Simplified Chinese “…”), the font's localized forms (OpenType `locl`: Turkish i, Bulgarian Cyrillic, Japanese / Chinese / Korean glyph variants) follow it too, and so does the fallback for CJK characters the font lacks (with Draw Missing Glyphs from Fallback Fonts on): Japanese, Simplified Chinese, Traditional Chinese and Korean each try their own installed system fonts (Japanese starts with the craft-fonts Japanese faces, Mincho first, when built with them; Hangul, kana and Bopomofo pick their language's fonts whatever the language). Language names from IDML keep their spelling; locale codes as InDesign writes them for some languages (`de_DE_2006`, `nl_NL_2005`; any `ll`, `ll_CC`, `ll-CC`) are their language and region for all of the above, and Chinese is also recognised in its common spellings ("Simplified Chinese", "Chinese (Traditional)", `zh_CN`, `zh-Hant`). Spanish is hyphenated with Javier Bezos's Liang patterns (`hyph-es.tex` 5.0, MIT) through the `hypher` crate; hyphenation for other languages isn't bundled yet. - Books (`book.new/open/add/remove/list/styleSource/paginate/syncStyles/exportPdf`, Book panel): `.dcbook` files, continuous page numbering, style sync from a style source, one merged PDF. - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. @@ -132,10 +154,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. -- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers. +- Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; plain and PDF/X-4 exports preserve their visible layers as vectors. - Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted). - Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width. -- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups). +- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; PDF export writes Knockout Group as a knockout transparency group (`/K true`) and Isolate Blending as `/I true`. - Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step). - Style Export Tagging (`style.exportTag`, Paragraph Style Options ▸ Export Tagging): paragraph and character styles choose their HTML element and class in EPUB/HTML. - Tagged Text: export (`file.exportText {format: "tagged"}`) and place (detected by its header; ASCII, UTF-8 or UTF-16) with paragraph and character styles, font, typeface and size, and `<0x…>` characters. @@ -152,14 +174,14 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. - Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. -- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). +- File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed through the system spooler as the PDF File › Export PDF writes with the same settings (effects, flattener, print color, warnings) (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. - Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. - Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). -- Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. +- Edit › Keyboard Shortcuts (the shared craft-keymap dialog): every command grouped by menu, recording with conflicts asked about, undo, named shortcut sets, an on-screen US keyboard, InDesign set import with a review, JSON export and a printable summary; `window.setShortcut`, `window.shortcutSets`. - Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). - Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used. - UI scaling (Preferences › Interface, 50–200%; `window.uiScale`); the window title steps aside when the bar is crowded. @@ -171,7 +193,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. @@ -358,10 +380,44 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. +- **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. +- **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. +- **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. +- **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change. +- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. +- **2026-10-20** · PDF/X-1a:2003 export (craft-libs#194): `file.exportPdf {standard: "x1a"}`, the Export PDF dialog's PDF/X-1a:2003 preset and standard. The PDF/X-4 print file is flattened in its inks by craft-prepress (no transparency; spot, process, overprint and live text kept; complex regions as DeviceN images at the Transparency Flattener preset's resolution), then written as PDF 1.4 with the X-1a identification and checked; `validate-pdf --standard x1a`. Soft drop shadows reach the flattener in their swatch's inks; other soft effects are still RGB images. No readiness percentage change. +- **2026-10-20** · Print color (craft-libs#190): CMYK print PDFs blend in DeviceCMYK through craft-pdf's print settings instead of byte rewrites, with the CMYK working space as the output intent in PDF/X-4 only; overprint (Overprint Fill, Stroke and Gap, overprinting text, 100% [Black]) is written as `/op` `/OP` `/OPM 1`; Knockout Group writes `/K true` instead of becoming an image; File › Print prints the PDF Export PDF writes. No readiness percentage change. +- **2026-10-20** · Separations Preview shows the plates the exported PDF prints, spot plates included: each visible spread is exported in memory with the Export PDF settings and separated by craft-prepress off the UI thread, then shown as all inks, one plate or an ink limit. The Ink Manager builds craft-prepress's `InkSet` from the document for export and preview. On the sample magazine the first preview of a spread takes about 0.6–1.1 s, most of it the PDF export. No readiness percentage change. +- **2026-10-20** · Spot gradients print on their plates. A gradient stop keeps the swatch it was given (Gradient panel drop, `swatch.create {gradient}`, `object.gradient`, IDML `StopColor`), following its renames and edits. PDF export shades one spot with its tints or [Paper] as a Separation, and a spot with process inks or a second spot as DeviceN; a process gradient to [Paper] prints in DeviceCMYK. Documents with plain-color stops export as before. No readiness percentage change. +- **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change. +- **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change. +- **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change. +- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. +- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. +- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. +- **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. +- **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. + +- **2026-10-24** · Optical Margin Alignment and hanging punctuation apply to lines with a tab or a right indent tab, as both composers already measured them: the edge glyphs hang, and tab stops stay where they are. Such a line was set without the hang: a justified line that fit only because its period or closing quote hangs had its word spaces squeezed to make up for it, and text after a right indent tab stopped at the measure instead of hanging. No readiness percentage change. +- **2026-10-23** · `designcraft-cli mcp` takes `--automation-read-root` and `--automation-write-root` more than once, as the suite's other apps do (#1552): a path inside any root of its kind passes, and the roots' common parent, `..` or a link out of them and the other kind's roots stay refused. No readiness percentage change. +- **2026-10-23** · Layout › Go Back and Go Forward (Cmd+Page Up / Cmd+Page Down, Ctrl on Windows and Linux) retrace the jumps between pages (Go to Page, the page and spread commands, the Pages panel) as InDesign's do, returning to where the view was on each page at the current zoom; a new jump after going back drops the pages ahead, scrolling adds no entry, and pages deleted since are skipped. No readiness percentage change. +- **2026-10-24** · The Story Editor has InDesign's vertical depth ruler between the style column and the text, and View › Story Editor › Show/Hide Depth Ruler (`view.storyEditorDepthRuler`) shows or hides it in the Story Editor, remembered with the preferences. The ruler is marked in the document's vertical ruler units with how deep the story's text fills its frames, counted through its columns and threaded frames (lines set side by side, in the sub-columns of a split paragraph or on both sides of a wrap object, count once), and is shaded down to the overset point, where a red line now crosses the editor. No readiness percentage change. +- **2026-10-24** · Split Note (`note.split {story, id, at}`, the Notes panel's Split Note button): as in InDesign, a note splits in two at the insertion point in its text. DesignCraft moves the text after it into a new note by the same author, anchored right after the first. No readiness percentage change. +- **2026-10-24** · Type › Notes › Previous Note and Next Note (`note.previous`, `note.next`): as in InDesign, they move the insertion point from note to note in the story, putting the caret after the note's anchor. Each item is unavailable when no note lies in its direction in the story, or when the insertion point is in a table cell or a footnote. No readiness percentage change. +- **2026-10-24** · Type › Notes › Convert to Note (`note.convertFromText {author?}`): as in InDesign, the selected text leaves the story and becomes the text of a new note, whose anchor takes its place; Convert Note to Text puts it back. DesignCraft refuses a selection in a table cell, or one that holds a note, footnote, endnote, anchored object, index marker, text anchor, cross-reference or table. No readiness percentage change. +- **2026-10-23** · Effects dialog: with Use Global Light on, the Angle field (and the Bevel and Emboss one) stays editable and shows the document's global light angle; a change there sets the global light for every effect that uses it, applied with the effects in one undo step (`object.effects` takes `globalLight`). Before, the field was greyed out and the angle couldn't be changed in the dialog. No readiness percentage change. +- **2026-10-24** · Story panel (Window › Type & Tables › Story): Optical Margin Alignment and its size for the stories of the text selection or the selected text frames, disabled without either; `type.selectionAttrs` reports them as `storyOptions`, null where the stories differ. The Paragraph panel menu has Ignore Optical Margin in place of the per-paragraph Optical Margin Alignment switch, which stays in `type.para`, and Paragraph Style Options › Indents and Spacing has Ignore Optical Margin (#947). No readiness percentage change. +- **2026-10-24** · Optical Margin Alignment per story with a size (`story.options {opticalMargin, opticalMarginSize}`, off by default): when it is on, the story's paragraphs hang punctuation and letter edges by amounts set for that type size, except paragraphs with Ignore Optical Margin (`ignoreOpticalMargin` in `type.para` and paragraph styles). Table cells and footnotes don't take the story setting yet. When it is off, the per-paragraph switch still hangs at the text's own size. IDML imports and exports both (`StoryPreference/@OpticalMarginAlignment`, `@OpticalMarginSize`, `IgnoreEdgeAlignment`) (#947). No readiness percentage change. +- **2026-10-24** · Text frames: a custom baseline grid's Relative To (Text Frame Options › Baseline Options, `object.textFrameOptions {baselineGrid: {relativeTo}}`, IDML `BaselineFrameGridRelativeOption`) measures the grid's start from the top of the page, the top margin, the top of the frame or its top inset. Text Frame Options had no custom baseline grid controls, imports dropped the setting and every frame grid started from the page top, so grid-aligned text in a frame measured from its own top sat on the wrong lines. No readiness percentage change. +- **2026-10-24** · Paragraph Style Options › Indents and Spacing has an Align to Grid menu: None, All Lines or First Line Only. It shows the style's current setting. A First Line Only style could be set only in the CJK Grid Settings section, which shows only with CJK features on, or through commands. No readiness percentage change. +- **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. +- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. - **2026-10-24** · Text frames: a custom baseline grid (Text Frame Options › Baseline Options) from an IDML file sets lines from its start by its increment. The composer read the two values in the other order, so lines landed on the wrong grid. No readiness percentage change. +- **2026-10-23** · File › File Info… edits the document's Document Title, Author, Description, Keywords, Copyright Notice and Copyright Info URL (`file.info`), and the document saves them. PDF export (PDF/X-4 and Print Booklet included) writes the title, author, description and keywords into the PDF's document information and XMP. EPUB export takes the title and author from File Info, and HTML export takes the title. A title or author given to the export replaces File Info's, and an empty Document Title leaves the document's name as the title. No readiness percentage change. - **2026-10-23** · Save and export dialogs (Save As, Save a Copy, Export PDF and the other exports, Package, libraries and books) start in the active document's folder once it has been saved, and for a document never saved in the folder of the last file saved or exported, remembered across sessions (#1653). A folder deleted since falls back to the system's choice. No readiness percentage change. - **2026-10-22** · Autoflow (Shift-click with the loaded text cursor, autoflow on Place, `text.thread {autoflow}`) fills the pages that follow before it adds any, as InDesign does: one frame per column on each following page, whatever else the page holds, then new pages at the end of the document. It inserted pages right after the story's last frame, in front of the document's own pages. Frames it set past the end of the text go again; a page the document had stays, and a page autoflow added only for such a frame goes with it. Smart Text Reflow of the primary text frame is unchanged. No readiness percentage change. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. @@ -371,6 +427,7 @@ Newest first. - **2026-10-22** · The Paragraph panel is laid out as InDesign's: the nine alignment buttons in groups; indent, spacing and drop cap fields by their icons in two columns, with Space Between Paragraphs Using Same Style (Ignore or a space); Shading and Border with the Paragraph Borders and Shading button and colour; Hyphenate with Do Not Align / Align to Baseline Grid. Its panel menu follows InDesign's order: Paragraph and Single-line Composer, Only Align First Line to Grid, Balance Ragged Lines, Optical Margin Alignment, Justification…, Keep Options…, Span Columns…, Hyphenation…, Drop Caps and Nested Styles…, GREP Styles… (these three now open for the selected paragraphs), Paragraph Rules…, Paragraph Borders and Shading…, Bullets and Numbering…, Restart/Continue Numbering, Define Lists… and the paragraph direction. Convert Bullets and Numbering to Text is listed but not available yet. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. +- **2026-10-23** · Type › Text Variables › Convert Variable to Text (`variables.convertToText`): the variable instances in the selected text, in a story, a table cell or a footnote, become plain text of the value each shows on its own page (a running header on page 3 becomes page 3's heading). `variables.convertAllToText {name}` does the same for every instance of one variable in the document, as the Text Variables dialog's Convert to Text button. The variable stays defined; one undo step. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. diff --git a/apps/designcraft-cli/examples/pdf_export.rs b/apps/designcraft-cli/examples/pdf_export.rs new file mode 100644 index 000000000..c427741a2 --- /dev/null +++ b/apps/designcraft-cli/examples/pdf_export.rs @@ -0,0 +1,64 @@ +//! File › Export PDF on the `designcraft-cli perf` stress document, timed. +//! +//! ```text +//! cargo run --release -p designcraft-cli --example pdf_export -- [--pages N] [--runs N] [--out FILE.pdf] +//! ``` +//! +//! Builds the synthetic document (200 pages, 1M characters in threaded stories, 500 placed +//! images, gradients, a drop shadow per spread), opens it in a headless session and runs +//! `file.exportPdf` on all pages: one warm-up export (composition), then `--runs` timed ones. +//! Prints each run, the median and the minimum. `--out` keeps the last PDF, written with a fixed +//! creation date so two builds can be compared byte for byte. + +use std::time::Instant; + +use designcraft_cli::stress; + +fn main() -> Result<(), String> { + let mut spec = stress::Spec::default(); + let mut runs = 7usize; + let mut out: Option = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + let mut num = || args.next().and_then(|v| v.parse::().ok()).ok_or(format!("{a} needs a number")); + match a.as_str() { + "--pages" => spec.pages = num()?, + "--runs" => runs = num()?.max(1), + "--out" => out = Some(args.next().ok_or("--out needs a file")?), + other => return Err(format!("unknown option `{other}`")), + } + } + let doc = stress::synthetic(&spec)?; + let bytes = designcraft_format::save(&doc).map_err(|e| e.to_string())?; + let dir = std::env::temp_dir().join(format!("designcraft-pdf-bench-{}", std::process::id())); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let file = dir.join("stress.designcraft"); + let keep = out.is_some(); + let pdf = out.map_or_else(|| dir.join("stress.pdf"), std::path::PathBuf::from); + std::fs::write(&file, bytes).map_err(|e| e.to_string())?; + let mut s = designcraft_engine::Session::new(); + s.execute("file.open", &serde_json::json!({"path": file})).map_err(|e| e.to_string())?; + let params = serde_json::json!({"path": pdf, "created": 1_700_000_000}); + let mut export = || -> Result { + let t = Instant::now(); + s.execute("file.exportPdf", ¶ms).map_err(|e| e.to_string())?; + Ok(t.elapsed().as_secs_f64() * 1000.0) + }; + let warm = export()?; + println!("{} pages, warm-up export (with composition) {warm:.1} ms", doc.page_count()); + let mut ms = Vec::with_capacity(runs); + for i in 0..runs { + let t = export()?; + println!("run {}: {t:.1} ms", i + 1); + ms.push(t); + } + ms.sort_by(f64::total_cmp); + let size = std::fs::metadata(&pdf).map_or(0, |m| m.len()); + println!("median {:.1} ms, min {:.1} ms, {} kB", ms[ms.len() / 2], ms[0], size / 1024); + let _ = std::fs::remove_file(&file); + if !keep { + let _ = std::fs::remove_file(&pdf); + } + let _ = std::fs::remove_dir(&dir); + Ok(()) +} diff --git a/apps/designcraft-cli/src/automation_args.rs b/apps/designcraft-cli/src/automation_args.rs index 52ef34f20..2ad9af786 100644 --- a/apps/designcraft-cli/src/automation_args.rs +++ b/apps/designcraft-cli/src/automation_args.rs @@ -3,30 +3,22 @@ use std::path::PathBuf; +#[derive(Default)] pub struct AutomationArgs { - pub read_root: Option, - pub write_root: Option, - read_given: bool, - write_given: bool, + pub read_roots: Vec, + pub write_roots: Vec, } impl AutomationArgs { - pub fn new(read_root: Option, write_root: Option) -> Self { - Self { read_root, write_root, read_given: false, write_given: false } - } - - /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, - /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + /// Returns whether this argument is a root flag. Each flag may be given more than once (#1552), + /// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder, none replaces one. pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); - let (root, given) = match flag { - "--automation-read-root" => (&mut self.read_root, &mut self.read_given), - "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + let roots = match flag { + "--automation-read-root" => &mut self.read_roots, + "--automation-write-root" => &mut self.write_roots, _ => return Ok(false), }; - if *given { - return Err(format!("{flag} given twice")); - } let value = match inline { Some(value) => value.to_owned(), None => { @@ -40,8 +32,7 @@ impl AutomationArgs { if value.is_empty() { return Err(format!("{flag} needs a non-empty folder")); } - *root = Some(value.into()); - *given = true; + roots.push(value.into()); Ok(true) } } @@ -51,40 +42,43 @@ mod tests { use super::*; #[test] - fn roots_are_taken_once_and_other_arguments_are_left() { - let mut roots = AutomationArgs::new(None, None); + fn roots_add_up_and_other_arguments_are_left() { + let mut roots = AutomationArgs::default(); let mut args = ["a folder".to_owned(), "doc.designcraft".to_owned()].into_iter(); assert!(roots.consume("--automation-read-root", &mut args).unwrap()); assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); assert!(!roots.consume("--sample", &mut args).unwrap()); - assert_eq!(roots.read_root, Some("a folder".into())); - assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(roots.read_roots, [PathBuf::from("a folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("out")]); assert_eq!(args.next().as_deref(), Some("doc.designcraft")); - assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); - assert_eq!(roots.read_root, Some("a folder".into())); + // #1552: a flag given again adds a folder, in either spelling, and keeps the first. + assert!(roots.consume("--automation-read-root=other", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]); + assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]); } #[test] fn invalid_operands_leave_roots_unchanged() { for flag in ["--automation-read-root", "--automation-write-root"] { for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { - let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] }; let mut args = operand.map(str::to_owned).into_iter(); assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); - assert_eq!(roots.read_root, Some("read".into())); - assert_eq!(roots.write_root, Some("write".into())); + assert_eq!(roots.read_roots, [PathBuf::from("read")]); + assert_eq!(roots.write_roots, [PathBuf::from("write")]); } - let mut roots = AutomationArgs::new(None, None); + let mut roots = AutomationArgs::default(); assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); } } #[test] fn explicit_dash_folder_and_relative_dash_folder_are_valid() { - let mut roots = AutomationArgs::new(None, None); + let mut roots = AutomationArgs::default(); assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); - assert_eq!(roots.read_root, Some("-folder".into())); - assert_eq!(roots.write_root, Some("./-folder".into())); + assert_eq!(roots.read_roots, [PathBuf::from("-folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]); } } diff --git a/apps/designcraft-cli/src/lib.rs b/apps/designcraft-cli/src/lib.rs new file mode 100644 index 000000000..f63ceb415 --- /dev/null +++ b/apps/designcraft-cli/src/lib.rs @@ -0,0 +1,5 @@ +//! Headless DesignCraft's shared parts: the synthetic stress document of `designcraft-cli perf`, +//! which the PDF export benchmark (`examples/pdf_export.rs`) exports too. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod stress; diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index faa4f4af0..9ce15674c 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -10,11 +10,12 @@ //! # run a command script (crates/engine/src/script.rs): `$N.path` references //! designcraft-cli app [--port PORT] COMMAND [JSON] # run a command in the running app (designcraft --control PORT) //! designcraft-cli app [--port PORT] --method METHOD [JSON] # any control-channel method (ui.screenshot, ui.render, …) -//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR] +//! designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]... //! # MCP server over stdio (docs/mcp.md); the roots confine the files it reads and writes //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document -//! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks +//! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the +//! # file's own GTS_PDFXVersion) //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ``` @@ -39,7 +40,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure). -const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]\n the two roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. A root left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; +const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going] @@ -122,7 +123,7 @@ fn main() -> ExitCode { Some("mcp") => report(mcp(&args[1..])), Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), - Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))), + Some("validate-pdf") => report(validate_pdf(args.get(1..).unwrap_or_default())), Some("links") => { use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}"); @@ -153,14 +154,38 @@ fn report(r: Result<(), String>) -> ExitCode { } } -fn validate_pdf(path: Option<&str>) -> Result<(), String> { - let path = path.ok_or("usage: designcraft-cli validate-pdf FILE.pdf")?; +fn validate_pdf(args: &[String]) -> Result<(), String> { + const USAGE: &str = "usage: designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a]"; + const X1A: &[u8] = b"/GTS_PDFXVersion(PDF/X-1:"; + let mut path = None; + let mut standard = None; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--standard" => standard = Some(it.next().ok_or(USAGE)?.as_str()), + p if path.is_none() => path = Some(p), + _ => return Err(USAGE.into()), + } + } + let path = path.ok_or(USAGE)?; let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; - let issues = designcraft_pdf::check_pdfx4(&bytes); + let standard = match standard.map(designcraft_pdf::Standard::parse) { + Some(Some(s)) => s, + Some(None) => return Err(USAGE.into()), + None if bytes.windows(X1A.len()).any(|w| w == X1A) => designcraft_pdf::Standard::PdfX1a, + None => designcraft_pdf::Standard::PdfX4, + }; + let (name, issues) = match standard { + designcraft_pdf::Standard::PdfX1a => ("PDF/X-1a:2003", designcraft_pdf::check_pdfx1a(&bytes)), + designcraft_pdf::Standard::PdfX4 => ("PDF/X-4", designcraft_pdf::check_pdfx4(&bytes)), + _ => return Err(USAGE.into()), + }; + let (warnings, issues): (Vec, Vec) = issues.into_iter().partition(|i| i.starts_with("warning: ")); + let warnings: Vec<&str> = warnings.iter().map(|w| w.trim_start_matches("warning: ")).collect(); let valid = issues.is_empty(); - let report = json!({"path": path, "standard": "PDF/X-4", "valid": valid, "issues": issues}); + let report = json!({"path": path, "standard": name, "valid": valid, "issues": issues, "warnings": warnings}); outln!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?); - if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) } + if valid { Ok(()) } else { Err(format!("{path} failed {name} validation")) } } /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app @@ -170,7 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> { use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; let mut connect: Option = None; let mut sample = false; - let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut roots_args = automation_args::AutomationArgs::default(); let mut it = args.iter().cloned(); while let Some(a) = it.next() { if roots_args.consume(&a, &mut it)? { @@ -181,12 +206,14 @@ fn mcp(args: &[String]) -> Result<(), String> { "--sample" => sample = true, other => { return Err(format!( - "unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR])" + "unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...)" )); } } } - let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect(); + let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect(); + let roots = AutomationRoots::new(&read, &write)?; if roots.is_some() && connect.is_some() { return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ so drop --connect (or use --connect without the roots)" @@ -209,8 +236,7 @@ so drop --connect (or use --connect without the roots)" if let Some(roots) = roots { // Every thread of this process, not only the server's: nothing reaches past the roots. file_access::confine_process(roots.clone()); - let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - eprintln!("designcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + eprintln!("designcraft-cli: files confined: {}", roots.describe()); h = h.with_automation_roots(Some(roots)); } Box::new(h) diff --git a/apps/designcraft-cli/src/perf.rs b/apps/designcraft-cli/src/perf.rs index 0c665f7e7..3a3afe931 100644 --- a/apps/designcraft-cli/src/perf.rs +++ b/apps/designcraft-cli/src/perf.rs @@ -10,255 +10,18 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Instant; -use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; use designcraft_compose::Cache; -use designcraft_doc::build::NewDocument; -use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, StoryId}; -use designcraft_geom::{Affine, Rect, Vec2, shapes}; +use designcraft_doc::{Document, SpreadRef, StoryId}; +use designcraft_geom::{Affine, Rect, Vec2}; use designcraft_render::{Placed, RenderOptions, Renderer}; +use designcraft_cli::stress::{Spec, synthetic}; + /// Device pixels per point at 100% (HiDPI canvas). const HIDPI: f64 = 2.0; const W: u32 = 2880; const H: u32 = 1800; -/// Deterministic xorshift in 0..1. -struct Rng(u64); -impl Rng { - fn next(&mut self) -> f64 { - self.0 ^= self.0 << 13; - self.0 ^= self.0 >> 7; - self.0 ^= self.0 << 17; - (self.0 % 1_000_000) as f64 / 1_000_000.0 - } - fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { - v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)] - } -} - -const WORDS: &[&str] = &[ - "the", - "grid", - "page", - "margin", - "column", - "rhythm", - "baseline", - "reader", - "story", - "frame", - "quiet", - "layout", - "type", - "paragraph", - "composer", - "weighs", - "every", - "line", - "break", - "texture", - "even", - "hyphen", - "image", - "spread", - "pulse", - "caption", - "colour", - "swatch", - "ink", - "tint", - "gradient", - "style", - "voice", - "printer", - "proof", - "bleed", - "a", - "of", - "and", - "to", - "in", - "with", - "for", - "is", - "on", - "that", - "by", - "it", - "as", - "careful", - "measure", - "leading", - "kerning", - "tracking", - "glyph", - "serif", - "italic", - "heading", - "running", - "folio", -]; - -/// `chars` characters of original filler text in paragraphs of ~400 characters. -fn filler(r: &mut Rng, chars: usize) -> String { - let mut s = String::with_capacity(chars + 16); - let mut para = 0; - while s.len() < chars { - let w = r.pick(WORDS); - if para == 0 { - let mut c = w.chars(); - if let Some(f) = c.next() { - s.extend(f.to_uppercase()); - s.push_str(c.as_str()); - } - } else { - s.push_str(w); - } - para += w.len() + 1; - if para > 400 { - s.push_str(".\n"); - para = 0; - } else { - s.push(' '); - } - } - s -} - -/// Procedural RGB image, PNG-encoded. -fn art_png(w: u32, h: u32, seed: f32) -> Vec { - let mut px = Vec::with_capacity((w * h * 4) as usize); - for y in 0..h { - for x in 0..w { - let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); - let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0); - px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]); - } - } - designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png() -} - -/// Parameters of the synthetic stress document. -pub struct Spec { - pub pages: usize, - pub frames_per_page: usize, - pub frames_per_story: usize, - pub chars: usize, - pub images: usize, - pub assets: usize, -} - -impl Default for Spec { - fn default() -> Self { - Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 } - } -} - -/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text -/// frames, and a strip of placed images (one with a drop shadow per spread). -pub fn synthetic(spec: &Spec) -> Result { - let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() }); - let lid = d.default_layer(); - d.swatches.push(Swatch { - name: "Perf Gradient".into(), - value: SwatchValue::Gradient { - gradient: Gradient { - kind: GradientKind::Linear, - stops: vec![ - GradientStop { offset: 0.0, color: Color::cmyk(0.6, 0.9, 0.3, 0.2), opacity: 1.0, midpoint: 0.5 }, - GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.6, 0.8, 0.0), opacity: 1.0, midpoint: 0.5 }, - ], - }, - }, - locked: false, - named: true, - hidden: false, - }); - let mut rng = Rng(0x5eed); - let (aw, ah) = (800u32, 600u32); - let assets: Vec = (0..spec.assets.max(1)) - .map(|k| { - let id = AssetId(d.alloc()); - let data = art_png(aw, ah, k as f32 * 0.37); - d.assets.insert( - id, - Arc::new(Asset { - id, - name: format!("art-{k}.png"), - mime: "image/png".into(), - data: Arc::new(data), - pixels: Some((aw, ah)), - ..Default::default() - }), - ); - id - }) - .collect(); - let total_frames = spec.pages * spec.frames_per_page; - let stories = total_frames.div_ceil(spec.frames_per_story.max(1)); - let per_story = spec.chars / stories.max(1); - let rows = spec.frames_per_page.div_ceil(2); - let (top, strip) = (48.0, 130.0); - let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64; - let mut prev: Option = None; - let mut n_frame = 0usize; - let mut placed_images = 0usize; - for abs in 0..spec.pages { - let Some((si, pi)) = d.page_loc(abs) else { break }; - let sr = SpreadRef::Doc(si); - let x0 = d.spreads[si].pages[pi].x; - // Gradient band. - let id = ItemId(d.alloc()); - let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0))); - band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() }; - band.fill.gradient_angle = Some(rng.next() * 90.0); - d.insert_item(sr, band, None).map_err(|e| e.to_string())?; - // Text frames. - for k in 0..spec.frames_per_page { - let (c, r) = (k % 2, k / 2); - let fx = x0 + 36.0 + c as f64 * 280.0; - let fy = top + r as f64 * row_h; - let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0); - let first = n_frame.is_multiple_of(spec.frames_per_story.max(1)); - let text = if first { filler(&mut rng, per_story) } else { String::new() }; - let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).map_err(|e| e.to_string())?; - if let (false, Some(p)) = (first, prev) { - d.thread(p, fid).map_err(|e| e.to_string())?; - } - prev = Some(fid); - n_frame += 1; - } - // Images: spread the requested count evenly over the pages. - let want = (spec.images * (abs + 1)) / spec.pages - placed_images; - for k in 0..want { - let w = 540.0 / want as f64; - let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0); - let asset = assets[(placed_images + k) % assets.len()]; - let id = ItemId(d.alloc()); - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - let (nw, nh) = (aw as f64, ah as f64); - let s = (r.width() / nw).max(r.height() / nh); - it.content = Content::Graphic(Graphic { - asset, - size: (nw, nh), - xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), - clip: None, - auto_fit: designcraft_doc::Fitting::FillProportionally, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - if k == 0 && pi == 0 { - it.effects.drop_shadow.on = true; - } - d.insert_item(sr, it, None).map_err(|e| e.to_string())?; - } - placed_images += want; - } - Ok(d) -} - fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { let mut v: Vec = (0..runs.max(1)) .map(|_| { @@ -372,11 +135,11 @@ fn measure(doc: &Document, runs: usize) -> Result, String> { let _ = std::fs::write(format!("{d}/{name}.png"), img.to_png()); } }; - save("fit", r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0))); + save("fit", r.render(doc, &cache, &all, (W, H), fit.1, &greek(fit.0))); row( &mut rows, format!("render spread, fit ({:.0}%), {W}×{H}", fit.0 / HIDPI * 100.0), - median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, fit.1, &greek(fit.0)))), + median_ms(runs, || drop(r.render(doc, &cache, &all, (W, H), fit.1, &greek(fit.0)))), Some(16.0), ); for zoom in [0.25, 1.0, 4.0] { @@ -384,12 +147,12 @@ fn measure(doc: &Document, runs: usize) -> Result, String> { // At 100% look at the text block of the left page. let c = if zoom >= 1.0 { centre - Vec2::new(placed[mid].1.width() / 4.0, 100.0) } else { centre }; let v = view_at(c, s); - save(&format!("zoom-{:.0}", zoom * 100.0), r.render(doc, &cache, &all, W, H, v, &greek(s))); + save(&format!("zoom-{:.0}", zoom * 100.0), r.render(doc, &cache, &all, (W, H), v, &greek(s))); let budget = if zoom == 1.0 { Some(16.0) } else { None }; row( &mut rows, format!("render at {:.0}%, {W}×{H}", zoom * 100.0), - median_ms(runs, || drop(r.render(doc, &cache, &all, W, H, v, &greek(s)))), + median_ms(runs, || drop(r.render(doc, &cache, &all, (W, H), v, &greek(s)))), budget, ); } @@ -398,13 +161,13 @@ fn measure(doc: &Document, runs: usize) -> Result, String> { let pan = median_ms(runs, || { dy += 37.0; let v = view_at(centre + Vec2::new(-200.0, dy - 300.0), HIDPI); - drop(r.render(doc, &cache, &all, W, H, v, &greek(HIDPI))); + drop(r.render(doc, &cache, &all, (W, H), v, &greek(HIDPI))); }); row(&mut rows, "render at 100% while panning", pan, Some(16.0)); // Cold renderer (no glyph-path / image caches): first frame after open. let cold = median_ms(runs.min(3), || { let mut r = Renderer::new(); - drop(r.render(doc, &cache, &all, W, H, view_at(centre, HIDPI), &greek(HIDPI))); + drop(r.render(doc, &cache, &all, (W, H), view_at(centre, HIDPI), &greek(HIDPI))); }); row(&mut rows, "render at 100%, cold renderer", cold, None); @@ -425,7 +188,7 @@ fn measure(doc: &Document, runs: usize) -> Result, String> { st.rev += 1; k += 1; } - drop(r.render(&d, &cache, &all, W, H, v, &greek(HIDPI))); + drop(r.render(&d, &cache, &all, (W, H), v, &greek(HIDPI))); }); row(&mut rows, "keystroke: recompose story + full repaint at 100%", ms, None); // What the canvas does: repaint only the damaged region (designcraft_render::damage). @@ -454,7 +217,7 @@ fn measure(doc: &Document, runs: usize) -> Result, String> { if let Some(p) = px { let (x0, y0) = (p.x0.floor(), p.y0.floor()); let (w, h) = ((p.x1.ceil() - x0) as u32, (p.y1.ceil() - y0) as u32); - drop(r.render(&d, &cache, &all, w.max(1), h.max(1), Affine::translate((-x0, -y0)) * v, &greek(HIDPI))); + drop(r.render(&d, &cache, &all, (w.max(1), h.max(1)), Affine::translate((-x0, -y0)) * v, &greek(HIDPI))); } }); row(&mut rows, "keystroke: recompose story + damage repaint at 100%", ms, Some(8.0)); diff --git a/apps/designcraft-cli/src/stress.rs b/apps/designcraft-cli/src/stress.rs new file mode 100644 index 000000000..c9d5b3015 --- /dev/null +++ b/apps/designcraft-cli/src/stress.rs @@ -0,0 +1,244 @@ +//! The synthetic stress document `designcraft-cli perf` measures, also used by the PDF export +//! benchmark (`examples/pdf_export.rs`). + +use std::sync::Arc; + +use designcraft_color::{Color, Gradient, GradientKind, GradientStop, Swatch, SwatchValue}; +use designcraft_doc::build::NewDocument; +use designcraft_doc::{Asset, AssetId, Content, Document, Fill, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef}; +use designcraft_geom::{Affine, Rect, shapes}; + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } + fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str { + v[((self.next() * v.len() as f64) as usize).min(v.len() - 1)] + } +} + +const WORDS: &[&str] = &[ + "the", + "grid", + "page", + "margin", + "column", + "rhythm", + "baseline", + "reader", + "story", + "frame", + "quiet", + "layout", + "type", + "paragraph", + "composer", + "weighs", + "every", + "line", + "break", + "texture", + "even", + "hyphen", + "image", + "spread", + "pulse", + "caption", + "colour", + "swatch", + "ink", + "tint", + "gradient", + "style", + "voice", + "printer", + "proof", + "bleed", + "a", + "of", + "and", + "to", + "in", + "with", + "for", + "is", + "on", + "that", + "by", + "it", + "as", + "careful", + "measure", + "leading", + "kerning", + "tracking", + "glyph", + "serif", + "italic", + "heading", + "running", + "folio", +]; + +/// `chars` characters of original filler text in paragraphs of ~400 characters. +fn filler(r: &mut Rng, chars: usize) -> String { + let mut s = String::with_capacity(chars + 16); + let mut para = 0; + while s.len() < chars { + let w = r.pick(WORDS); + if para == 0 { + let mut c = w.chars(); + if let Some(f) = c.next() { + s.extend(f.to_uppercase()); + s.push_str(c.as_str()); + } + } else { + s.push_str(w); + } + para += w.len() + 1; + if para > 400 { + s.push_str(".\n"); + para = 0; + } else { + s.push(' '); + } + } + s +} + +/// Procedural RGB image, PNG-encoded. +fn art_png(w: u32, h: u32, seed: f32) -> Vec { + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let v = ((fx * 9.0 + seed).sin() * (fy * 7.0 - seed).cos() * 0.5 + 0.5).clamp(0.0, 1.0); + px.extend([(v * 255.0) as u8, ((1.0 - fy) * 200.0) as u8, ((fx * 0.6 + seed * 0.1).fract() * 255.0) as u8, 255]); + } + } + designcraft_render::Rendered { width: w, height: h, pixels: px }.to_png() +} + +/// Parameters of the synthetic stress document. +pub struct Spec { + pub pages: usize, + pub frames_per_page: usize, + pub frames_per_story: usize, + pub chars: usize, + pub images: usize, + pub assets: usize, +} + +impl Default for Spec { + fn default() -> Self { + Spec { pages: 200, frames_per_page: 10, frames_per_story: 50, chars: 1_000_000, images: 500, assets: 25 } + } +} + +/// A `spec.pages`-page facing document: per page a gradient band, a 2×N grid of threaded text +/// frames, and a strip of placed images (one with a drop shadow per spread). +pub fn synthetic(spec: &Spec) -> Result { + let mut d = Document::new(&NewDocument { pages: spec.pages, ..Default::default() }); + let lid = d.default_layer(); + d.swatches.push(Swatch { + name: "Perf Gradient".into(), + value: SwatchValue::Gradient { + gradient: Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::cmyk(0.6, 0.9, 0.3, 0.2)), GradientStop::new(1.0, Color::cmyk(0.0, 0.6, 0.8, 0.0))], + }, + }, + locked: false, + named: true, + hidden: false, + }); + let mut rng = Rng(0x5eed); + let (aw, ah) = (800u32, 600u32); + let assets: Vec = (0..spec.assets.max(1)) + .map(|k| { + let id = AssetId(d.alloc()); + let data = art_png(aw, ah, k as f32 * 0.37); + d.assets.insert( + id, + Arc::new(Asset { + id, + name: format!("art-{k}.png"), + mime: "image/png".into(), + data: Arc::new(data), + pixels: Some((aw, ah)), + ..Default::default() + }), + ); + id + }) + .collect(); + let total_frames = spec.pages * spec.frames_per_page; + let stories = total_frames.div_ceil(spec.frames_per_story.max(1)); + let per_story = spec.chars / stories.max(1); + let rows = spec.frames_per_page.div_ceil(2); + let (top, strip) = (48.0, 130.0); + let row_h = (792.0 - 36.0 - top - strip - 8.0) / rows as f64; + let mut prev: Option = None; + let mut n_frame = 0usize; + let mut placed_images = 0usize; + for abs in 0..spec.pages { + let Some((si, pi)) = d.page_loc(abs) else { break }; + let sr = SpreadRef::Doc(si); + let x0 = d.spreads[si].pages[pi].x; + // Gradient band. + let id = ItemId(d.alloc()); + let mut band = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(x0 + 36.0, 20.0, x0 + 576.0, 40.0))); + band.fill = Fill { swatch: "Perf Gradient".into(), ..Fill::none() }; + band.fill.gradient_angle = Some(rng.next() * 90.0); + d.insert_item(sr, band, None).map_err(|e| e.to_string())?; + // Text frames. + for k in 0..spec.frames_per_page { + let (c, r) = (k % 2, k / 2); + let fx = x0 + 36.0 + c as f64 * 280.0; + let fy = top + r as f64 * row_h; + let rect = Rect::new(fx, fy, fx + 260.0, fy + row_h - 6.0); + let first = n_frame.is_multiple_of(spec.frames_per_story.max(1)); + let text = if first { filler(&mut rng, per_story) } else { String::new() }; + let (fid, _) = d.add_text_frame(sr, rect, lid, &text, ParaFormat::default()).map_err(|e| e.to_string())?; + if let (false, Some(p)) = (first, prev) { + d.thread(p, fid).map_err(|e| e.to_string())?; + } + prev = Some(fid); + n_frame += 1; + } + // Images: spread the requested count evenly over the pages. + let want = (spec.images * (abs + 1)) / spec.pages - placed_images; + for k in 0..want { + let w = 540.0 / want as f64; + let r = Rect::new(x0 + 36.0 + k as f64 * w, 792.0 - 36.0 - strip, x0 + 36.0 + (k + 1) as f64 * w - 8.0, 792.0 - 36.0); + let asset = assets[(placed_images + k) % assets.len()]; + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + let (nw, nh) = (aw as f64, ah as f64); + let s = (r.width() / nw).max(r.height() / nh); + it.content = Content::Graphic(Graphic { + layer_options: None, + asset, + size: (nw, nh), + xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s), + clip: None, + auto_fit: designcraft_doc::Fitting::FillProportionally, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + if k == 0 && pi == 0 { + it.effects.drop_shadow.on = true; + } + d.insert_item(sr, it, None).map_err(|e| e.to_string())?; + } + placed_images += want; + } + Ok(d) +} diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 173a605f4..861cb514f 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -347,6 +347,29 @@ fn make_document(path: &std::path::Path) { assert!(path.exists()); } +/// `path` without the `\\?\` or `\\?\UNC\` prefix that `canonicalize` adds on Windows, as the +/// server shows a root (`file_access::plain`). +fn without_verbatim_prefix(path: std::path::PathBuf) -> std::path::PathBuf { + let Some(text) = path.to_str() else { + return path; + }; + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return std::path::PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => std::path::PathBuf::from(rest), + None => path, + } +} + +#[test] +fn without_verbatim_prefix_keeps_a_share_a_share() { + let p = |t: &str| without_verbatim_prefix(std::path::PathBuf::from(t)); + assert_eq!(p(r"\\?\UNC\server\share\dir"), std::path::PathBuf::from(r"\\server\share\dir")); + assert_eq!(p(r"\\?\C:\Users\dir"), std::path::PathBuf::from(r"C:\Users\dir")); + assert_eq!(p("/private/var/dir"), std::path::PathBuf::from("/private/var/dir")); +} + #[test] fn mcp_automation_roots_confine_the_server() { let base = std::env::temp_dir().join(format!("dc-cli-roots-{}", std::process::id())); @@ -401,6 +424,99 @@ fn mcp_automation_roots_confine_the_server() { let _ = std::fs::remove_dir_all(base); } +/// #1552: each root flag may be given more than once. A path inside any root of its kind passes; +/// the roots' common parent, a sibling whose name starts like a root's, `..` out of a root, the +/// other kind's roots and a link out of a root stay refused. +#[test] +fn mcp_automation_roots_take_several_folders() { + let base = std::env::temp_dir().join(format!("dc-cli-roots-several-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in", "shared", "out", "scratch", "outside", "in2", "scratch2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + // Resolved, as the server shows the roots (`/var` is `/private/var` on macOS), and without the + // `\\?\` prefix that `canonicalize` adds on Windows: the server shows the roots without it, and + // Windows does not resolve `..` in a path that has it. + let base = without_verbatim_prefix(std::fs::canonicalize(base).unwrap()); + for f in ["in/a.designcraft", "shared/b.designcraft", "outside/secret.designcraft", "parent.designcraft", "in2/c.designcraft"] { + make_document(&base.join(f)); + } + // Windows' canonical `\\?\` form is not a prefix under which `..` is a path component: + // traversal requests use the ordinary spelling, while the other requests keep the resolved one. + let plain = |path: &std::path::Path| -> String { + let text = path.to_string_lossy(); + if !cfg!(windows) { + return text.into_owned(); + } + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => rest.to_owned(), + None => text.into_owned(), + } + }; + let p = |f: &str| base.join(f).to_str().unwrap().to_string(); + let sep = std::path::MAIN_SEPARATOR; + let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", plain(&base.join(root))); + let calls = [ + ("open_document", serde_json::json!({"path": p("in/a.designcraft")})), + ("open_document", serde_json::json!({"path": p("shared/b.designcraft")})), + // The roots' common parent is not a root. + ("open_document", serde_json::json!({"path": p("parent.designcraft")})), + ("open_document", serde_json::json!({"path": up("shared", &format!("outside{sep}secret.designcraft"))})), + // `in2` only starts like `in`. + ("open_document", serde_json::json!({"path": p("in2/c.designcraft")})), + // `..` from one read root into another stays inside. + ("open_document", serde_json::json!({"path": up("shared", &format!("in{sep}a.designcraft"))})), + ("save_document", serde_json::json!({"path": p("out/x.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch/y.designcraft")})), + ("save_document", serde_json::json!({"path": p("z.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch2/z.designcraft")})), + // Read and write stay separate: a read root is not a write root, nor the other way round. + ("save_document", serde_json::json!({"path": p("shared/w.designcraft")})), + ("open_document", serde_json::json!({"path": p("out/x.designcraft")})), + ("export_png", serde_json::json!({"path": up("scratch", &format!("outside{sep}p.png"))})), + ]; + let read2 = format!("--automation-read-root={}", p("shared")); + let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("scratch")]; + let (success, stderr, replies) = mcp_session(&args, &calls); + assert!(success, "{stderr}"); + let confined = format!( + "files confined: read {}, {}; write {}, {}", + plain(&base.join("in")), + plain(&base.join("shared")), + plain(&base.join("out")), + plain(&base.join("scratch")) + ); + assert!(stderr.contains(&confined), "expected {confined:?} in {stderr:?}"); + let errors: Vec = replies.iter().map(|r| r["result"]["isError"] == true).collect(); + assert_eq!(errors, [false, false, true, true, true, false, false, false, true, true, true, true, true], "{replies:?}"); + for (i, kind) in [(2, "read"), (3, "read"), (4, "read"), (8, "write"), (9, "write"), (10, "write"), (11, "read"), (12, "write")] { + assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{i}: {}", text(&replies[i])); + } + assert!(base.join("out/x.designcraft").is_file() && base.join("scratch/y.designcraft").is_file()); + for f in ["z.designcraft", "scratch2/z.designcraft", "shared/w.designcraft", "outside/p.png"] { + assert!(!base.join(f).exists(), "{f} was written outside the write roots"); + } + + // A link in one root that leads out of all of them is outside, for reading and for writing. + #[cfg(unix)] + { + std::os::unix::fs::symlink(base.join("outside/secret.designcraft"), base.join("shared/leak.designcraft")).unwrap(); + std::os::unix::fs::symlink(base.join("outside"), base.join("scratch/away")).unwrap(); + let calls = [ + ("open_document", serde_json::json!({"path": p("shared/leak.designcraft")})), + ("save_document", serde_json::json!({"path": p("scratch/away/x.designcraft")})), + ]; + let (_, _, replies) = mcp_session(&args, &calls); + assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0])); + assert!(text(&replies[1]).contains("outside the write roots"), "{}", text(&replies[1])); + assert!(!base.join("outside/x.designcraft").exists()); + } + let _ = std::fs::remove_dir_all(base); +} + #[test] fn mcp_automation_root_flags_are_checked_up_front() { let base = std::env::temp_dir().join(format!("dc-cli-roots-args-{}", std::process::id())); @@ -419,9 +535,11 @@ fn mcp_automation_root_flags_are_checked_up_front() { // The flags confine the in-process server, so they cannot be combined with --connect. fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); - fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); - fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); - fails(&["--automation-read-root", dir, "--automation-read-root=x"], "given twice"); + // #1552: a flag may be given again, but every folder it names must exist and be given. + fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root", missing.to_str().unwrap(), "--automation-write-root", dir], "cannot open the automation write root"); + fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder"); + fails(&["--automation-read-root", dir, "--automation-read-root="], "needs a non-empty folder"); fails(&["--automation-read-root"], "needs a folder"); fails(&["--automation-write-root", "--sample"], "needs a folder"); fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index 27d236a2f..19bc3a122 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -350,9 +350,14 @@ struct WebShell { impl eframe::App for WebShell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let graphics = self.graphics.clone(); - let _ = graphics.run_if_healthy(|| { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { + let _ = graphics.run_if_healthy(|| self.app.logic(ctx)); + } + + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let _ = self.graphics.run_if_healthy(|| { + // Dropped files belong to this host iteration's raw input, not to stale logic input: + // read them here once, before the batch is saved as pending for a hidden pass. + for f in std::mem::take(&mut raw.dropped_files) { let request = self.app.import_request("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); @@ -367,14 +372,8 @@ impl eframe::App for WebShell { } }); } - self.app.logic(ctx); - }); - } - - fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - let _ = self.graphics.run_if_healthy(|| { designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused() || self.app.canvas_ime()); - self.app.raw_input_hook(raw); + self.app.raw_input_hook(ctx, raw); }); } @@ -396,7 +395,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let dialog = if request.purpose == "swatches" { rfd::AsyncFileDialog::new().add_filter("Swatch Exchange (ASE)", &["ase"]) } else if request.purpose == "shortcuts" { - rfd::AsyncFileDialog::new().add_filter("Shortcut set (text)", &["txt"]) + rfd::AsyncFileDialog::new().add_filter("Shortcut set (JSON, InDesign text)", &["json", "txt"]) } else if request.purpose == "place" { rfd::AsyncFileDialog::new().add_filter("Graphics", IMAGE_EXTS) } else { @@ -411,8 +410,6 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { ctx.request_repaint(); }); })), - // Exports ask for a name; the browser decides where the download goes. - pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), write: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes))), inbox: Some(inbox), diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 7dc83bc55..d706374c7 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] designcraft-engine = { workspace = true } designcraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "clipboard"] } egui = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -24,6 +24,8 @@ log = { workspace = true } winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Not called here: craft-dialogs shows the dialogs. rfd's default features keep its Wayland +# support on, so portal dialogs get the window as their parent. rfd = { workspace = true } arboard = { workspace = true } @@ -32,6 +34,7 @@ arboard = { workspace = true } winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } # EnumDisplayDevices (src/gpu.rs): which GPU drives the display, so the window draws on the GPU # that shows it; drawing on a GPU without a monitor reset the display driver. Safe wrappers over # Win32 (MIT); the `user` feature is User32.dll only. @@ -41,10 +44,12 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"] } # The same winit as eframe; only the native termination opt-in is used. winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } -# The input context, to drop the IME's marked text when the Type tool ends a composition; the -# window's `movable` flag (see `mac_window`). The versions muda uses, through safe methods. +# The input context, to drop the IME's marked text when the Type tool ends a composition. The +# versions muda uses, through safe methods. objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext", "NSWindow"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSResponder", "NSTextInputContext"] } +# The window's `movable` flag follows the application bar's controls. +craft-window = { workspace = true } # Finder double-clicks, Open With, Dock drops and `open -a DesignCraft brochure.idml` arrive as a # kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by PhotoCraft, # PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe, diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index 01b3ca6d4..907117b0c 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -758,583 +758,4 @@ fn restart(skip: &[String], startup: &Startup) -> std::io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { - Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } - } - - fn names(c: &[Candidate], order: &[usize]) -> Vec { - order.iter().map(|&i| c[i].name.clone()).collect() - } - - const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); - const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); - const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); - const WARP: (u32, u32) = (0x1414, 0x008c); - - fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { - Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } - } - - fn monitor(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: false } - } - - fn primary(pci: (u32, u32)) -> DisplayGpu { - DisplayGpu { pci, primary: true } - } - - /// A Windows desktop as wgpu lists it with every backend: a Ryzen's integrated GPU without a - /// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and - /// OpenGL (which reports no device id). - fn ryzen_desktop() -> Vec { - vec![ - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), - pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), - ] - } - - /// Power saving picked the integrated GPU, whose driver reset when Windows had to present its - /// frames on the monitors of the NVIDIA card. The GPU that drives the displays comes first, - /// through DX12; the integrated GPU stays the fallback a restart reaches. - #[test] - fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { - let c = ryzen_desktop(); - for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); - } - // Without the display information the order is power saving's. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); - // After the NVIDIA adapters failed, the integrated GPU is next. - let skip = vec![c[2].key.clone(), c[0].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); - } - - /// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the - /// discrete one. Power saving and the display agree, in either enumeration order. - #[test] - fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { - let c = ryzen_desktop(); - for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); - } - // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the - // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one - // comes after both. - let linux = vec![ - pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), - pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), - pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), - pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), - ]; - let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; - assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); - assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); - } - - /// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so - /// its GPU draws, whichever kind it is. - #[test] - fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { - let c = ryzen_desktop(); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); - // The displays only matter among adapters that can show the window; a GPU driving no - // display still loses to one that does. - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); - } - - /// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, - /// Metal's zeros) change nothing: power saving's order. - #[test] - fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { - let c = ryzen_desktop(); - let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); - } - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); - // The Wayland desktop's adapters report no ids in these tests: the primary display never matches. - let c = wayland_desktop(); - assert_eq!( - adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), - adapter_order(&c, PowerPreference::None, &[], None, &[]) - ); - assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); - } - - /// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. - #[test] - fn a_named_adapter_comes_before_the_one_driving_the_display() { - let mut c = ryzen_desktop(); - let displays = [primary(NVIDIA_3090)]; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); - c[2].presents = false; - c[0].presents = false; - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); - } - - /// `WGPU_POWER_PREF`, the user's own choice, is kept as it is; only without it do the displays - /// count. - #[test] - fn only_automatic_considers_which_gpu_drives_the_display() { - assert!(automatic(None)); - assert!(!automatic(Some(PowerPreference::None))); - assert!(!automatic(Some(PowerPreference::LowPower))); - assert!(preferred_displays(Some(PowerPreference::HighPerformance)).is_empty()); - // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). - assert_eq!(preferred_displays(None), display_gpus()); - } - - #[test] - fn pci_ids_come_from_windows_device_ids() { - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); - assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); - assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); - // Remote Desktop and other non-PCI display devices, and malformed ids. - assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); - assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); - assert_eq!(pci_ids("VEN_&DEV_"), None); - assert_eq!(pci_ids("PCI\\VEN_10DÉ&DEV_2204"), None); - assert_eq!(pci_ids(""), None); - } - - /// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary - /// display, and none is listed twice. (A headless CI runner may list none.) - #[cfg(windows)] - #[test] - fn windows_lists_each_gpu_with_a_monitor_once() { - let gpus = windows_display_gpus(); - assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); - for (i, g) in gpus.iter().enumerate() { - assert_ne!(g.pci.0, 0, "{gpus:?}"); - assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); - } - } - - #[test] - fn internal_panels_are_edp_lvds_and_dsi_connectors() { - for name in ["eDP-1", "LVDS-1", "DSI-1"] { - assert!(is_internal_panel(name), "{name}"); - } - for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { - assert!(!is_internal_panel(name), "{name}"); - } - } - - /// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port - /// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without - /// PCI ids. Each GPU is listed once, the one with the panel as primary. - #[test] - fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { - let dir = std::env::temp_dir().join(format!("designcraft-drm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name).join("device"))?; - std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; - std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) - }; - let connector = |name: &str, status: &str| -> std::io::Result<()> { - std::fs::create_dir_all(dir.join(name))?; - std::fs::write(dir.join(name).join("status"), format!("{status}\n")) - }; - card("card0", AMD_IGPU)?; - card("card1", NVIDIA_3090)?; - std::fs::create_dir_all(dir.join("card2"))?; - connector("card0-eDP-1", "connected")?; - connector("card0-HDMI-A-1", "disconnected")?; - connector("card1-DP-1", "connected")?; - connector("card1-DP-2", "connected")?; - connector("card2-Virtual-1", "connected")?; - std::fs::write(dir.join("renderD128"), "")?; - let mut gpus = linux_display_gpus(&dir); - gpus.sort_by_key(|g| g.pci); - assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); - assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); - std::fs::remove_dir_all(&dir) - } - - #[test] - fn display_gpus_are_logged_as_pci_ids() { - assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); - assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); - let mut gpus = vec![]; - add_display_gpu(&mut gpus, NVIDIA_3090, false); - add_display_gpu(&mut gpus, NVIDIA_3090, true); - add_display_gpu(&mut gpus, AMD_IGPU, false); - assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); - } - - /// A dual-GPU Linux desktop as Vulkan lists it with Mesa's device-select layer: the GPU KDE's - /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software - /// renderer, and OpenGL. - fn wayland_desktop() -> Vec { - vec![ - gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), - gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), - ] - } - - #[test] - fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { - let c = wayland_desktop(); - let order = adapter_order(&c, power_preference(None), &[], None, &[]); - let first = &c[order[0]].name; - if cfg!(any(windows, target_os = "macos")) { - assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - } else { - // The integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. - assert_eq!(first, "NVIDIA GeForce RTX 5070"); - } - } - - #[test] - fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { - let c = wayland_desktop(); - let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); - assert_eq!( - order(PowerPreference::None), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::LowPower), - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - assert_eq!( - order(PowerPreference::HighPerformance), - [ - "NVIDIA GeForce RTX 5070", - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - /// A restart leaves out the adapter the window failed on, and once every adapter failed there - /// is nothing to try. A dual-GPU Mac reports both GPUs under Metal with no PCI ids: their keys - /// still differ, so when the integrated GPU fails, the restart renders on the discrete one. - #[test] - fn gpus_without_pci_ids_are_told_apart_by_name() { - let mac = |name: &str, device_type| Candidate { - key: key(Backend::Metal, 0, 0, name), - name: name.into(), - backend: Backend::Metal, - device_type, - pci: (0, 0), - presents: true, - }; - let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; - assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); - let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); - let skip = parse_skip(&c[first[0]].key); - assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); - // A comma in a name can't split the list. - assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); - } - - fn sample(frames: u64, busy: bool, leaves: u64, hidden: bool) -> Sample { - Sample { frames, busy, leaves, hidden } - } - - /// Runs `ticks` looks with the same sample; the verdicts other than `Wait`. - fn run(w: &mut Watch, s: Sample, ticks: u32) -> Vec<(u32, Verdict)> { - (1..=ticks).filter_map(|t| Some((t, w.step(s, WATCH_TICK))).filter(|(_, v)| *v != Verdict::Wait)).collect() - } - - const TICKS_PER_S: u32 = 4; - - /// The UI thread stuck in the graphics stack (not in the app's code, no progress) with the - /// window not known to be hidden: late after 20 s, hung after 60 s. - #[test] - fn a_ui_thread_stuck_in_the_graphics_stack_hangs_its_adapter() { - let mut w = Watch::default(); - let v = run(&mut w, sample(1, false, 1, false), 61 * TICKS_PER_S); - assert_eq!(v.first(), Some(&(20 * TICKS_PER_S, Verdict::Late))); - // `leaves` moved from 0 to 1 on the first look, so the stuck time starts one look later. - assert_eq!(v.get(1), Some(&(60 * TICKS_PER_S + 1, Verdict::Hung))); - // The second frame began: the first was presented. - assert_eq!(Watch::default().step(sample(2, false, 9, false), WATCH_TICK), Verdict::Shown); - } - - /// A busy UI thread (a large document composing on a slow machine) is only ever late. - #[test] - fn a_busy_ui_thread_never_hangs_its_adapter() { - let mut w = Watch::default(); - let v = run(&mut w, sample(0, true, 0, false), 600 * TICKS_PER_S); - assert_eq!(v, [(20 * TICKS_PER_S, Verdict::Late)]); - // Nor does one that keeps coming back from the graphics stack. - let mut w = Watch::default(); - for t in 0..(600 * TICKS_PER_S) { - assert_ne!(w.step(sample(1, false, u64::from(t), false), WATCH_TICK), Verdict::Hung); - } - } - - /// A window known to be hidden (locked screen, another workspace, minimized) may have its frames - /// held back by the compositor: that time counts for nothing and starts the stuck time over. - #[test] - fn time_while_the_window_is_hidden_does_not_count() { - let mut w = Watch::default(); - assert!(run(&mut w, sample(1, false, 0, true), 600 * TICKS_PER_S).is_empty()); - assert!(run(&mut w, sample(1, false, 0, false), 50 * TICKS_PER_S).iter().all(|(_, v)| *v == Verdict::Late)); - assert!(run(&mut w, sample(1, false, 0, true), 10 * TICKS_PER_S).is_empty()); - assert!(run(&mut w, sample(1, false, 0, false), 59 * TICKS_PER_S).is_empty()); - assert_eq!(run(&mut w, sample(1, false, 0, false), TICKS_PER_S), [(TICKS_PER_S, Verdict::Hung)]); - } - - #[test] - fn a_hung_adapter_is_left_out_once() { - let skip = vec!["Dx12:10de:28a0".to_string()]; - assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); - assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); - } - - /// Windows, a hardware DX12 adapter failing: the software renderer (WARP) is passed over while - /// another backend is left, the restarts go through OpenGL and Vulkan, and only then back to - /// every backend with software allowed. The chain ends. - #[test] - fn software_renderers_come_after_every_backends_hardware() { - use backend::Backend as B; - let c = ryzen_desktop(); - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - assert_eq!(without_software(order.clone(), &c, false), (order.clone(), false)); - let (hardware, dropped) = without_software(order, &c, true); - assert!(dropped && !hardware.contains(&4), "{hardware:?}"); - - let dx12 = "Dx12:10de:2204".to_string(); - // The adapter that failed first. - let (skip, _) = next_start(&[], Some(&dx12), Some((B::Dx12, Some(B::Gl))), true).unwrap(); - assert_eq!(skip, [dx12.as_str()]); - // Only WARP left on DX12 (passed over, nothing picked): OpenGL next. - let (skip, what) = next_start(&skip, None, Some((B::Dx12, Some(B::Gl))), true).unwrap(); - assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT]); - assert!(what.contains("OpenGL"), "{what}"); - // OpenGL has no adapter, Vulkan next; then Vulkan has none and no backend is left. - let (skip, _) = next_start(&skip, None, Some((B::Gl, Some(B::Vulkan))), false).unwrap(); - assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT, "Gl"]); - let (skip, what) = next_start(&skip, None, Some((B::Vulkan, None)), false).unwrap(); - assert_eq!(skip, [dx12.as_str(), SOFTWARE], "{what}"); - assert!(backend::skipped_backends(&skip).is_empty()); - // With software allowed and nothing left: the end. - assert_eq!(next_start(&skip, None, Some((B::Dx12, None)), false), None); - // Nothing was passed over (a Mac whose GPUs all failed): no software round. - assert_eq!(next_start(&[], None, Some((B::Metal, None)), false), None); - // Without the backend fallback (`WGPU_BACKEND`), only adapters. - assert_eq!(next_start(&[], None, None, false), None); - } - - /// The skip list travels as an argument, which processes the app starts don't inherit. - #[test] - fn the_skip_list_is_an_argument() { - assert_eq!(skip_arg("--gpu-skip=Dx12:10de:2204,Dx12"), Some(vec!["Dx12:10de:2204".to_string(), "Dx12".to_string()])); - assert_eq!(skip_arg("--gpu-skip="), Some(vec![])); - assert_eq!(skip_arg("--gpu-skip"), None); - assert_eq!(skip_arg("layout.designcraft"), None); - } - - #[test] - fn a_restart_tries_the_next_adapter() { - let c = wayland_desktop(); - let mut skip = vec![]; - let mut tried = vec![]; - while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { - tried.push(c[i].name.clone()); - skip.push(c[i].key.clone()); - } - assert_eq!( - tried, - [ - "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", - "NVIDIA GeForce RTX 5070", - "NVIDIA GeForce RTX 5070/PCIe/SSE2", - "llvmpipe (LLVM 20.1.8, 256 bits)" - ] - ); - } - - #[test] - fn adapters_that_cannot_present_to_the_window_are_never_tried() { - let mut c = wayland_desktop(); - c[0].presents = false; - c[3].presents = false; - for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { - let order = adapter_order(&c, power, &[], None, &[]); - assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); - assert_eq!(order.len(), 2); - } - for p in &mut c { - p.presents = false; - } - assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); - assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); - } - - #[test] - fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { - let c = wayland_desktop(); - let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); - assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); - assert_eq!(order.len(), 4); - // Any case; every match comes before the rest, natively first. - let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); - assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); - // An empty name or one nothing matches changes nothing. - for named in ["", "no such gpu"] { - assert_eq!( - adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), - adapter_order(&c, PowerPreference::LowPower, &[], None, &[]) - ); - } - // A named adapter that failed is left out like any other. - let skip = vec![c[1].key.clone()]; - assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); - } - - /// A Windows machine lists every GPU twice when Vulkan and DX12 are both in the instance: - /// power saving still takes the integrated GPU first, each GPU through DX12 first. - #[test] - fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { - let c = vec![ - gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), - gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - ]; - let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); - assert_eq!(order, [3, 1, 2, 0, 4]); - assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); - } - - /// One Intel GPU whose Vulkan driver made the window flicker black: DX12 comes first whatever - /// the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` or a restart leaving DX12 - /// out still reach it. - #[test] - fn a_windows_gpu_renders_through_dx12_before_vulkan() { - let c = vec![ - gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), - gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), - gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), - gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), - ]; - for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); - } - assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); - } - - #[test] - fn power_preference_follows_the_environment() { - assert_eq!(power_preference(None), AUTOMATIC); - for p in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { - assert_eq!(power_preference(Some(p)), p); - } - assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); - } - - /// The Intel Mac of #334 lost its Metal device compiling the compute shader of wgpu's indirect - /// call check: off unless `WGPU_VALIDATION_INDIRECT_CALL` turns it on. - #[test] - fn the_indirect_call_check_is_off() { - if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { - assert!(!instance_flags().contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); - } - // The other flags of the build stay as wgpu sets them. - let others = wgpu::InstanceFlags::from_build_config() - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL; - if std::env::vars_os().all(|(k, _)| !k.to_string_lossy().starts_with("WGPU_")) { - assert_eq!(instance_flags(), others); - } - } - - #[test] - fn skipped_adapters_parse_from_a_comma_separated_list() { - assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); - assert!(parse_skip("").is_empty()); - assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); - assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); - // A whole backend left out is its bare name; it never matches an adapter's key. - let skip = parse_skip("Dx12,Dx12:10de:2204"); - assert_eq!(backend::skipped_backends(&skip), [backend::Backend::Dx12]); - let c = vec![pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090)]; - assert!(adapter_order(&c, PowerPreference::None, &[], None, &skip).is_empty()); - assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, &skip[..1]), [0]); - } - - #[test] - fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { - for file in [ - "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", - r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", - "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", - "naga-30.0.0/src/back/spv/writer.rs", - ] { - assert!(in_graphics_stack(file), "{file}"); - } - for file in [ - "apps/designcraft/src/main.rs", - "/home/wgpu/designcraft/crates/ui-egui/src/lib.rs", - "egui-0.36.2/src/context.rs", - "winit-0.30.12/src/x.rs", - ] { - assert!(!in_graphics_stack(file), "{file}"); - } - } - - #[test] - fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { - let amd = Some("Vulkan:1002:164e"); - assert_eq!(restart_without(true, 0, amd, &[]), amd); - assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); - // After start-up the adapter has shown frames; a lost window is something else. - assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); - // A bug outside the graphics, or no adapter picked: nothing another adapter would change. - assert_eq!(restart_without(false, 0, amd, &[]), None); - assert_eq!(restart_without(true, 0, None, &[]), None); - // Never the same adapter twice, so restarts end. - assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); - } - - /// Without a backend fallback (`WGPU_BACKEND` set), a start whose graphics failed with no - /// adapter to leave out ends with the error and the hint; a bug ends with its message. - #[test] - fn a_normal_exit_or_another_failure_does_not_restart() { - let startup = Startup::new(None, Vec::new()); - assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); - // No adapter was picked (none can show the window): the error is returned, nothing restarts. - let none = - eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); - assert_eq!( - finish(Ok(Err(none)), &startup), - Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) - ); - let _ = startup.adapter.set("Vulkan:1002:164e".into()); - GRAPHICS_PANIC.store(false, Ordering::Relaxed); - assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); - // Nothing to record without a fallback. - startup.presented(); - startup.started_with(wgpu::Backend::Gl); - assert_eq!(startup.status_line(), None); - } -} +mod tests; diff --git a/apps/designcraft/src/gpu/tests.rs b/apps/designcraft/src/gpu/tests.rs new file mode 100644 index 000000000..c7990aab0 --- /dev/null +++ b/apps/designcraft/src/gpu/tests.rs @@ -0,0 +1,571 @@ +use super::*; + +fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate { + Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, pci: (0, 0), presents: true } +} + +fn names(c: &[Candidate], order: &[usize]) -> Vec { + order.iter().map(|&i| c[i].name.clone()).collect() +} + +const NVIDIA_3090: (u32, u32) = (0x10de, 0x2204); +const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); +const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); +const WARP: (u32, u32) = (0x1414, 0x008c); + +fn pci_gpu(name: &str, backend: Backend, device_type: DeviceType, pci: (u32, u32)) -> Candidate { + Candidate { key: key(backend, pci.0, pci.1, name), name: name.into(), backend, device_type, pci, presents: true } +} + +fn monitor(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: false } +} + +fn primary(pci: (u32, u32)) -> DisplayGpu { + DisplayGpu { pci, primary: true } +} + +/// A Windows desktop as wgpu lists it with every backend: a Ryzen's integrated GPU without a +/// monitor and an RTX 3090 driving both monitors, each under Vulkan and DX12, then WARP and +/// OpenGL (which reports no device id). +fn ryzen_desktop() -> Vec { + vec![ + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("AMD Radeon(TM) Graphics", Backend::Dx12, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu, WARP), + pci_gpu("NVIDIA GeForce RTX 3090/PCIe/SSE2", Backend::Gl, DeviceType::Other, (NVIDIA_3090.0, 0)), + ] +} + +/// Power saving picked the integrated GPU, whose driver reset when Windows had to present its +/// frames on the monitors of the NVIDIA card. The GPU that drives the displays comes first, +/// through DX12; the integrated GPU stays the fallback a restart reaches. +#[test] +fn a_desktop_draws_on_the_gpu_that_drives_its_monitors() { + let c = ryzen_desktop(); + for displays in [vec![primary(NVIDIA_3090)], vec![monitor(NVIDIA_3090)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + assert_eq!(adapter_order(&c, PowerPreference::None, &displays, None, &[]), [2, 0, 3, 1, 5, 4], "{displays:?}"); + } + // Without the display information the order is power saving's. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[]), [3, 1, 2, 0, 5, 4]); + // After the NVIDIA adapters failed, the integrated GPU is next. + let skip = vec![c[2].key.clone(), c[0].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &skip), [3, 1, 5, 4]); +} + +/// A hybrid laptop: the panel (primary) on the integrated GPU, an external monitor on the +/// discrete one. Power saving and the display agree, in either enumeration order. +#[test] +fn a_hybrid_laptop_draws_on_the_integrated_gpu_that_drives_its_panel() { + let c = ryzen_desktop(); + for displays in [vec![primary(AMD_IGPU), monitor(NVIDIA_3090)], vec![monitor(NVIDIA_3090), primary(AMD_IGPU)], vec![primary(AMD_IGPU)]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 2, 0, 5, 4], "{displays:?}"); + } + // Linux names no primary unless there is a built-in panel: a monitor on each GPU keeps the + // system's order among them (Mesa puts the compositor's GPU first), and a GPU without one + // comes after both. + let linux = vec![ + pci_gpu("AMD Radeon Graphics (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu, AMD_IGPU), + pci_gpu("NVIDIA GeForce RTX 3090", Backend::Vulkan, DeviceType::DiscreteGpu, NVIDIA_3090), + pci_gpu("Intel(R) Arc(TM) A380", Backend::Vulkan, DeviceType::DiscreteGpu, INTEL_IGPU), + pci_gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu, (0x10005, 0)), + ]; + let both = [monitor(AMD_IGPU), monitor(NVIDIA_3090)]; + assert_eq!(adapter_order(&linux, PowerPreference::None, &both, None, &[]), [0, 1, 2, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(INTEL_IGPU)], None, &[]), [2, 0, 1, 3]); + assert_eq!(adapter_order(&linux, PowerPreference::None, &[monitor(NVIDIA_3090), primary(AMD_IGPU)], None, &[]), [0, 1, 2, 3]); +} + +/// A Windows desktop with a monitor on each GPU: the window opens on the primary display, so +/// its GPU draws, whichever kind it is. +#[test] +fn the_primary_display_decides_between_gpus_that_both_drive_a_monitor() { + let c = ryzen_desktop(); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), primary(NVIDIA_3090)], None, &[]), [2, 0, 3, 1, 5, 4]); + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[primary(AMD_IGPU), monitor(NVIDIA_3090)], None, &[]), [3, 1, 2, 0, 5, 4]); + // The displays only matter among adapters that can show the window; a GPU driving no + // display still loses to one that does. + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None, &[]), [3, 1, 2, 0, 5, 4]); +} + +/// Display GPUs none of the adapters match (another driver's ids, OpenGL's missing device id, +/// Metal's zeros) change nothing: power saving's order. +#[test] +fn displays_no_adapter_matches_leave_the_power_preference_in_charge() { + let c = ryzen_desktop(); + let low = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + for displays in [vec![monitor(INTEL_IGPU)], vec![primary((0x8086, 0x1234)), monitor((0x8086, 0x5678))], vec![primary((0, 0))]] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), low, "{displays:?}"); + } + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::HighPerformance, &[monitor(INTEL_IGPU)], None, &[]))[0], "NVIDIA GeForce RTX 3090"); + // The Wayland desktop's adapters report no ids in these tests: the primary display never matches. + let c = wayland_desktop(); + assert_eq!( + adapter_order(&c, PowerPreference::None, &[primary(NVIDIA_3090)], None, &[]), + adapter_order(&c, PowerPreference::None, &[], None, &[]) + ); + assert!(adapter_order(&[], PowerPreference::LowPower, &[primary(NVIDIA_3090)], None, &[]).is_empty()); +} + +/// `WGPU_ADAPTER_NAME` and "can't present" still come before the displays. +#[test] +fn a_named_adapter_comes_before_the_one_driving_the_display() { + let mut c = ryzen_desktop(); + let displays = [primary(NVIDIA_3090)]; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, Some("radeon"), &[]), [3, 1, 2, 0, 5, 4]); + c[2].presents = false; + c[0].presents = false; + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &displays, None, &[]), [3, 1, 5, 4]); +} + +/// `WGPU_POWER_PREF`, the user's own choice, is kept as it is; only without it do the displays +/// count. +#[test] +fn only_automatic_considers_which_gpu_drives_the_display() { + assert!(automatic(None)); + assert!(!automatic(Some(PowerPreference::None))); + assert!(!automatic(Some(PowerPreference::LowPower))); + assert!(preferred_displays(Some(PowerPreference::HighPerformance)).is_empty()); + // Automatic reports whatever this machine has (nothing on macOS, in CI, or under Remote Desktop). + assert_eq!(preferred_displays(None), display_gpus()); +} + +#[test] +fn pci_ids_come_from_windows_device_ids() { + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_2204&SUBSYS_40421458&REV_A1"), Some(NVIDIA_3090)); + assert_eq!(pci_ids("PCI\\VEN_1002&DEV_164E&SUBSYS_88771043&REV_C1"), Some(AMD_IGPU)); + assert_eq!(pci_ids("pci\\ven_1002&dev_164e"), Some(AMD_IGPU)); + // Remote Desktop and other non-PCI display devices, and malformed ids. + assert_eq!(pci_ids("ROOT\\BasicDisplay\\0000"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&SUBSYS_40421458"), None); + assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_ZZZZ"), None); + assert_eq!(pci_ids("VEN_&DEV_"), None); + assert_eq!(pci_ids("PCI\\VEN_10DÉ&DEV_2204"), None); + assert_eq!(pci_ids(""), None); +} + +/// Whatever this machine has: every GPU listed is a PCI device, at most one drives the primary +/// display, and none is listed twice. (A headless CI runner may list none.) +#[cfg(windows)] +#[test] +fn windows_lists_each_gpu_with_a_monitor_once() { + let gpus = windows_display_gpus(); + assert!(gpus.iter().filter(|g| g.primary).count() <= 1, "{gpus:?}"); + for (i, g) in gpus.iter().enumerate() { + assert_ne!(g.pci.0, 0, "{gpus:?}"); + assert!(!gpus[..i].iter().any(|h| h.pci == g.pci), "{gpus:?}"); + } +} + +#[test] +fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!is_internal_panel(name), "{name}"); + } +} + +/// A sysfs like a hybrid laptop's with an external monitor: the panel and a disconnected port +/// on the integrated GPU (card0), the monitor on the discrete one (card1), and a card without +/// PCI ids. Each GPU is listed once, the one with the panel as primary. +#[test] +fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("designcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name).join("device"))?; + std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; + std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n")) + }; + let connector = |name: &str, status: &str| -> std::io::Result<()> { + std::fs::create_dir_all(dir.join(name))?; + std::fs::write(dir.join(name).join("status"), format!("{status}\n")) + }; + card("card0", AMD_IGPU)?; + card("card1", NVIDIA_3090)?; + std::fs::create_dir_all(dir.join("card2"))?; + connector("card0-eDP-1", "connected")?; + connector("card0-HDMI-A-1", "disconnected")?; + connector("card1-DP-1", "connected")?; + connector("card1-DP-2", "connected")?; + connector("card2-Virtual-1", "connected")?; + std::fs::write(dir.join("renderD128"), "")?; + let mut gpus = linux_display_gpus(&dir); + gpus.sort_by_key(|g| g.pci); + assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]); + assert!(linux_display_gpus(&dir.join("no such dir")).is_empty()); + std::fs::remove_dir_all(&dir) +} + +#[test] +fn display_gpus_are_logged_as_pci_ids() { + assert_eq!(primary(NVIDIA_3090).to_string(), "10de:2204 (primary)"); + assert_eq!(monitor(AMD_IGPU).to_string(), "1002:164e"); + let mut gpus = vec![]; + add_display_gpu(&mut gpus, NVIDIA_3090, false); + add_display_gpu(&mut gpus, NVIDIA_3090, true); + add_display_gpu(&mut gpus, AMD_IGPU, false); + assert_eq!(gpus, [primary(NVIDIA_3090), monitor(AMD_IGPU)]); +} + +/// A dual-GPU Linux desktop as Vulkan lists it with Mesa's device-select layer: the GPU KDE's +/// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software +/// renderer, and OpenGL. +fn wayland_desktop() -> Vec { + vec![ + gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu), + gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other), + ] +} + +#[test] +fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() { + let c = wayland_desktop(); + let order = adapter_order(&c, power_preference(None), &[], None, &[]); + let first = &c[order[0]].name; + if cfg!(any(windows, target_os = "macos")) { + assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + } else { + // The integrated GPU couldn't show frames to a compositor on the NVIDIA GPU. + assert_eq!(first, "NVIDIA GeForce RTX 5070"); + } +} + +#[test] +fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() { + let c = wayland_desktop(); + let order = |power| names(&c, &adapter_order(&c, power, &[], None, &[])); + assert_eq!( + order(PowerPreference::None), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::LowPower), + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); + assert_eq!( + order(PowerPreference::HighPerformance), + [ + "NVIDIA GeForce RTX 5070", + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +/// A restart leaves out the adapter the window failed on, and once every adapter failed there +/// is nothing to try. A dual-GPU Mac reports both GPUs under Metal with no PCI ids: their keys +/// still differ, so when the integrated GPU fails, the restart renders on the discrete one. +#[test] +fn gpus_without_pci_ids_are_told_apart_by_name() { + let mac = |name: &str, device_type| Candidate { + key: key(Backend::Metal, 0, 0, name), + name: name.into(), + backend: Backend::Metal, + device_type, + pci: (0, 0), + presents: true, + }; + let c = vec![mac("NVIDIA GeForce GT 750M", DeviceType::DiscreteGpu), mac("Intel Iris Pro Graphics", DeviceType::IntegratedGpu)]; + assert_eq!((c[0].key.as_str(), c[1].key.as_str()), ("Metal:NVIDIA GeForce GT 750M", "Metal:Intel Iris Pro Graphics")); + let first = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(names(&c, &first)[0], "Intel Iris Pro Graphics"); + let skip = parse_skip(&c[first[0]].key); + assert_eq!(names(&c, &adapter_order(&c, PowerPreference::LowPower, &[], None, &skip)), ["NVIDIA GeForce GT 750M"]); + // A comma in a name can't split the list. + assert_eq!(parse_skip(&key(Backend::Gl, 0, 0, "Mesa, llvmpipe")), ["Gl:Mesa llvmpipe"]); +} + +fn sample(frames: u64, busy: bool, leaves: u64, hidden: bool) -> Sample { + Sample { frames, busy, leaves, hidden } +} + +/// Runs `ticks` looks with the same sample; the verdicts other than `Wait`. +fn run(w: &mut Watch, s: Sample, ticks: u32) -> Vec<(u32, Verdict)> { + (1..=ticks).filter_map(|t| Some((t, w.step(s, WATCH_TICK))).filter(|(_, v)| *v != Verdict::Wait)).collect() +} + +const TICKS_PER_S: u32 = 4; + +/// The UI thread stuck in the graphics stack (not in the app's code, no progress) with the +/// window not known to be hidden: late after 20 s, hung after 60 s. +#[test] +fn a_ui_thread_stuck_in_the_graphics_stack_hangs_its_adapter() { + let mut w = Watch::default(); + let v = run(&mut w, sample(1, false, 1, false), 61 * TICKS_PER_S); + assert_eq!(v.first(), Some(&(20 * TICKS_PER_S, Verdict::Late))); + // `leaves` moved from 0 to 1 on the first look, so the stuck time starts one look later. + assert_eq!(v.get(1), Some(&(60 * TICKS_PER_S + 1, Verdict::Hung))); + // The second frame began: the first was presented. + assert_eq!(Watch::default().step(sample(2, false, 9, false), WATCH_TICK), Verdict::Shown); +} + +/// A busy UI thread (a large document composing on a slow machine) is only ever late. +#[test] +fn a_busy_ui_thread_never_hangs_its_adapter() { + let mut w = Watch::default(); + let v = run(&mut w, sample(0, true, 0, false), 600 * TICKS_PER_S); + assert_eq!(v, [(20 * TICKS_PER_S, Verdict::Late)]); + // Nor does one that keeps coming back from the graphics stack. + let mut w = Watch::default(); + for t in 0..(600 * TICKS_PER_S) { + assert_ne!(w.step(sample(1, false, u64::from(t), false), WATCH_TICK), Verdict::Hung); + } +} + +/// A window known to be hidden (locked screen, another workspace, minimized) may have its frames +/// held back by the compositor: that time counts for nothing and starts the stuck time over. +#[test] +fn time_while_the_window_is_hidden_does_not_count() { + let mut w = Watch::default(); + assert!(run(&mut w, sample(1, false, 0, true), 600 * TICKS_PER_S).is_empty()); + assert!(run(&mut w, sample(1, false, 0, false), 50 * TICKS_PER_S).iter().all(|(_, v)| *v == Verdict::Late)); + assert!(run(&mut w, sample(1, false, 0, true), 10 * TICKS_PER_S).is_empty()); + assert!(run(&mut w, sample(1, false, 0, false), 59 * TICKS_PER_S).is_empty()); + assert_eq!(run(&mut w, sample(1, false, 0, false), TICKS_PER_S), [(TICKS_PER_S, Verdict::Hung)]); +} + +#[test] +fn a_hung_adapter_is_left_out_once() { + let skip = vec!["Dx12:10de:28a0".to_string()]; + assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None); + assert_eq!(restart_without(true, 1, Some("Dx12:8086:a7a8"), &skip), Some("Dx12:8086:a7a8")); +} + +/// Windows, a hardware DX12 adapter failing: the software renderer (WARP) is passed over while +/// another backend is left, the restarts go through OpenGL and Vulkan, and only then back to +/// every backend with software allowed. The chain ends. +#[test] +fn software_renderers_come_after_every_backends_hardware() { + use backend::Backend as B; + let c = ryzen_desktop(); + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(without_software(order.clone(), &c, false), (order.clone(), false)); + let (hardware, dropped) = without_software(order, &c, true); + assert!(dropped && !hardware.contains(&4), "{hardware:?}"); + + let dx12 = "Dx12:10de:2204".to_string(); + // The adapter that failed first. + let (skip, _) = next_start(&[], Some(&dx12), Some((B::Dx12, Some(B::Gl))), true).unwrap(); + assert_eq!(skip, [dx12.as_str()]); + // Only WARP left on DX12 (passed over, nothing picked): OpenGL next. + let (skip, what) = next_start(&skip, None, Some((B::Dx12, Some(B::Gl))), true).unwrap(); + assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT]); + assert!(what.contains("OpenGL"), "{what}"); + // OpenGL has no adapter, Vulkan next; then Vulkan has none and no backend is left. + let (skip, _) = next_start(&skip, None, Some((B::Gl, Some(B::Vulkan))), false).unwrap(); + assert_eq!(skip, [dx12.as_str(), "Dx12", SOFTWARE_LEFT, "Gl"]); + let (skip, what) = next_start(&skip, None, Some((B::Vulkan, None)), false).unwrap(); + assert_eq!(skip, [dx12.as_str(), SOFTWARE], "{what}"); + assert!(backend::skipped_backends(&skip).is_empty()); + // With software allowed and nothing left: the end. + assert_eq!(next_start(&skip, None, Some((B::Dx12, None)), false), None); + // Nothing was passed over (a Mac whose GPUs all failed): no software round. + assert_eq!(next_start(&[], None, Some((B::Metal, None)), false), None); + // Without the backend fallback (`WGPU_BACKEND`), only adapters. + assert_eq!(next_start(&[], None, None, false), None); +} + +/// The skip list travels as an argument, which processes the app starts don't inherit. +#[test] +fn the_skip_list_is_an_argument() { + assert_eq!(skip_arg("--gpu-skip=Dx12:10de:2204,Dx12"), Some(vec!["Dx12:10de:2204".to_string(), "Dx12".to_string()])); + assert_eq!(skip_arg("--gpu-skip="), Some(vec![])); + assert_eq!(skip_arg("--gpu-skip"), None); + assert_eq!(skip_arg("layout.designcraft"), None); +} + +#[test] +fn a_restart_tries_the_next_adapter() { + let c = wayland_desktop(); + let mut skip = vec![]; + let mut tried = vec![]; + while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, &[], None, &skip).first() { + tried.push(c[i].name.clone()); + skip.push(c[i].key.clone()); + } + assert_eq!( + tried, + [ + "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", + "NVIDIA GeForce RTX 5070", + "NVIDIA GeForce RTX 5070/PCIe/SSE2", + "llvmpipe (LLVM 20.1.8, 256 bits)" + ] + ); +} + +#[test] +fn adapters_that_cannot_present_to_the_window_are_never_tried() { + let mut c = wayland_desktop(); + c[0].presents = false; + c[3].presents = false; + for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] { + let order = adapter_order(&c, power, &[], None, &[]); + assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}"); + assert_eq!(order.len(), 2); + } + for p in &mut c { + p.presents = false; + } + assert!(adapter_order(&c, PowerPreference::None, &[], None, &[]).is_empty()); + assert!(adapter_order(&[], PowerPreference::HighPerformance, &[], None, &[]).is_empty()); +} + +#[test] +fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() { + let c = wayland_desktop(); + let order = adapter_order(&c, PowerPreference::HighPerformance, &[], Some("radv"), &[]); + assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)"); + assert_eq!(order.len(), 4); + // Any case; every match comes before the rest, natively first. + let order = adapter_order(&c, PowerPreference::LowPower, &[], Some("GeForce"), &[]); + assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]); + // An empty name or one nothing matches changes nothing. + for named in ["", "no such gpu"] { + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, &[], None, &[])); + } + // A named adapter that failed is left out like any other. + let skip = vec![c[1].key.clone()]; + assert_eq!(adapter_order(&c, PowerPreference::None, &[], Some("radv"), &skip)[0], 0); +} + +/// A Windows machine lists every GPU twice when Vulkan and DX12 are both in the instance: +/// power saving still takes the integrated GPU first, each GPU through DX12 first. +#[test] +fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() { + let c = vec![ + gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu), + gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + ]; + let order = adapter_order(&c, PowerPreference::LowPower, &[], None, &[]); + assert_eq!(order, [3, 1, 2, 0, 4]); + assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, &[], None, &[]), [2, 0, 3, 1, 4]); +} + +/// One Intel GPU whose Vulkan driver made the window flicker black: DX12 comes first whatever +/// the preference; Vulkan stays a fallback, and `WGPU_ADAPTER_NAME` or a restart leaving DX12 +/// out still reach it. +#[test] +fn a_windows_gpu_renders_through_dx12_before_vulkan() { + let c = vec![ + gpu("Intel(R) Graphics", Backend::Vulkan, DeviceType::IntegratedGpu), + gpu("Intel(R) Graphics", Backend::Dx12, DeviceType::IntegratedGpu), + gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu), + gpu("Intel(R) Graphics", Backend::Gl, DeviceType::Other), + ]; + for power in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(adapter_order(&c, power, &[], None, &[]), [1, 0, 3, 2], "{power:?}"); + } + assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next"); +} + +#[test] +fn power_preference_follows_the_environment() { + assert_eq!(power_preference(None), AUTOMATIC); + for p in [PowerPreference::LowPower, PowerPreference::HighPerformance, PowerPreference::None] { + assert_eq!(power_preference(Some(p)), p); + } + assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None }); +} + +/// The Intel Mac of #334 lost its Metal device compiling the compute shader of wgpu's indirect +/// call check: off unless `WGPU_VALIDATION_INDIRECT_CALL` turns it on. +#[test] +fn the_indirect_call_check_is_off() { + if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() { + assert!(!instance_flags().contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)); + } + // The other flags of the build stay as wgpu sets them. + let others = wgpu::InstanceFlags::from_build_config() - wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL; + if std::env::vars_os().all(|(k, _)| !k.to_string_lossy().starts_with("WGPU_")) { + assert_eq!(instance_flags(), others); + } +} + +#[test] +fn skipped_adapters_parse_from_a_comma_separated_list() { + assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]); + assert!(parse_skip("").is_empty()); + assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e, "AMD Radeon"), "Vulkan:1002:164e"); + assert_eq!(key(Backend::Gl, 0x10de, 0x2f04, "NVIDIA"), "Gl:10de:2f04"); + // A whole backend left out is its bare name; it never matches an adapter's key. + let skip = parse_skip("Dx12,Dx12:10de:2204"); + assert_eq!(backend::skipped_backends(&skip), [backend::Backend::Dx12]); + let c = vec![pci_gpu("NVIDIA GeForce RTX 3090", Backend::Dx12, DeviceType::DiscreteGpu, NVIDIA_3090)]; + assert!(adapter_order(&c, PowerPreference::None, &[], None, &skip).is_empty()); + assert_eq!(adapter_order(&c, PowerPreference::None, &[], None, &skip[..1]), [0]); +} + +#[test] +fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() { + for file in [ + "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs", + r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs", + "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs", + "naga-30.0.0/src/back/spv/writer.rs", + ] { + assert!(in_graphics_stack(file), "{file}"); + } + for file in + ["apps/designcraft/src/main.rs", "/home/wgpu/designcraft/crates/ui-egui/src/lib.rs", "egui-0.36.2/src/context.rs", "winit-0.30.12/src/x.rs"] + { + assert!(!in_graphics_stack(file), "{file}"); + } +} + +#[test] +fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() { + let amd = Some("Vulkan:1002:164e"); + assert_eq!(restart_without(true, 0, amd, &[]), amd); + assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd); + // After start-up the adapter has shown frames; a lost window is something else. + assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None); + // A bug outside the graphics, or no adapter picked: nothing another adapter would change. + assert_eq!(restart_without(false, 0, amd, &[]), None); + assert_eq!(restart_without(true, 0, None, &[]), None); + // Never the same adapter twice, so restarts end. + assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None); +} + +/// Without a backend fallback (`WGPU_BACKEND` set), a start whose graphics failed with no +/// adapter to leave out ends with the error and the hint; a bug ends with its message. +#[test] +fn a_normal_exit_or_another_failure_does_not_restart() { + let startup = Startup::new(None, Vec::new()); + assert_eq!(finish(Ok(Ok(())), &startup), Ok(())); + // No adapter was picked (none can show the window): the error is returned, nothing restarts. + let none = eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into())); + assert_eq!( + finish(Ok(Err(none)), &startup), + Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})")) + ); + let _ = startup.adapter.set("Vulkan:1002:164e".into()); + GRAPHICS_PANIC.store(false, Ordering::Relaxed); + assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into())); + // Nothing to record without a fallback. + startup.presented(); + startup.started_with(wgpu::Backend::Gl); + assert_eq!(startup.status_line(), None); +} diff --git a/apps/designcraft/src/mac_window.rs b/apps/designcraft/src/mac_window.rs deleted file mode 100644 index b7fd267f1..000000000 --- a/apps/designcraft/src/mac_window.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! macOS: AppKit moves the window when a drag starts anywhere in the title-bar band, on the -//! application bar's controls too. The window is made unmovable while the pointer is on one of -//! them (`designcraft_ui_egui::title_bar::window_movable`), so only the bar's empty space moves it. - -use objc2::MainThreadMarker; -use objc2_app_kit::{NSApplication, NSWindowStyleMask}; - -/// Sets the window's `movable` flag from this frame's title bar. `applied` holds the value last -/// set, so AppKit is only called when it changes. -pub fn sync_movable(ctx: &egui::Context, applied: &mut Option) { - let movable = designcraft_ui_egui::title_bar::window_movable(ctx); - if *applied == Some(movable) { - return; - } - // eframe runs the app on the main thread; anywhere else AppKit is out of reach. - let Some(mtm) = MainThreadMarker::new() else { - return; - }; - let mut found = false; - for window in NSApplication::sharedApplication(mtm).windows().to_vec() { - // The main window is the only one with a full-size content view. - if window.styleMask().contains(NSWindowStyleMask::FullSizeContentView) { - window.setMovable(movable); - found = true; - } - } - // Before the window is on screen there is nothing to set yet; try again next frame. - if found { - *applied = Some(movable); - } -} diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 195a2dc6e..5f056aab3 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -20,8 +20,6 @@ mod gpu; mod gpu_limits; mod logging; #[cfg(target_os = "macos")] -mod mac_window; -#[cfg(target_os = "macos")] mod native_menu; mod profile; @@ -37,7 +35,7 @@ struct App { /// Documents and quit requests from Finder, the Dock and Open With. #[cfg(target_os = "macos")] apple_events: fmv_macos_events::Inbox, - /// The window's `movable` flag as last set; see `mac_window`. + /// The window's `movable` flag as last set; see `craft_window::title_bar`. #[cfg(target_os = "macos")] movable: Option, /// Frames begun so far. @@ -51,39 +49,45 @@ struct App { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.startup.enter(); - if let Some(note) = self.startup.take_notice() { - self.app.status(note); - } - // The third call: two frames went through `ui`, so the first was presented and the driver - // survived it (a fault at the first present is what the fallback in `gpu` is for). - if self.frames == 2 { - self.startup.presented(); - } - self.frames = self.frames.saturating_add(1); - if u64::from(self.frames) >= gpu::STARTUP_FRAMES - && let Some(device) = self.log_errors_after_startup.take() - { - gpu_limits::log_uncaptured_errors(&device); - } - #[cfg(target_os = "macos")] - { - if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { - self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ctx)); - } - if let Some(m) = &mut self.menu { - m.poll(&mut self.app, ctx); - } - // Noted for a start again after a graphics failure, which reopens them (`gpu::restart`). - self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ctx)); - } + // Queue/deadline work only. A hidden `run_logic` tick also reports pass index zero, so + // startup counters and native-menu polling must not live here. self.app.logic(ctx); self.startup.leave(); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.startup.enter(); + // `App::ui` is the only signal that a real layout pass ran. A discarded multipass first + // pass reports index 0 once, so these once-per-frame counters/menu polls still run once. + if ui.ctx().current_pass_index() == 0 { + if let Some(note) = self.startup.take_notice() { + self.app.status(note); + } + // The third UI callback: two frames went through `ui`, so the first was presented and + // the driver survived it (a fault at the first present is what `gpu` falls back for). + if self.frames == 2 { + self.startup.presented(); + } + self.frames = self.frames.saturating_add(1); + if u64::from(self.frames) >= gpu::STARTUP_FRAMES + && let Some(device) = self.log_errors_after_startup.take() + { + gpu_limits::log_uncaptured_errors(&device); + } + #[cfg(target_os = "macos")] + { + if self.menu.is_none() && std::env::var_os("DESIGNCRAFT_NO_NATIVE_MENU").is_none() { + self.menu = Some(native_menu::NativeMenu::install(&mut self.app, ui.ctx())); + } + if let Some(m) = &mut self.menu { + m.poll(&mut self.app, ui.ctx()); + } + // Noted for a start again after a graphics failure, which reopens them (`gpu::restart`). + self.startup.opening(apple_events::poll(&self.apple_events, &mut self.app, ui.ctx())); + } + } self.app.ui(ui); self.startup.leave(); #[cfg(target_os = "macos")] @@ -91,7 +95,10 @@ impl eframe::App for App { discard_marked_text(); } #[cfg(target_os = "macos")] - mac_window::sync_movable(ui.ctx(), &mut self.movable); + craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); + // After the frame, never inside it: the dialog answers on a later frame. On macOS Save and + // message boxes are sheets on this window. + self.app.show_file_dialogs(Some(&*frame)); } fn on_exit(&mut self) { // A normal exit before the third frame is not a crash. @@ -143,15 +150,48 @@ fn prefs_path() -> Option { profile::config_dir().map(|b| b.join("ui.json")) } +/// Set when `ui.json` could not be read in full and no copy of it could be kept: the file is +/// then left as it is rather than overwritten. +static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Copy an unreadable (or partly unreadable) `ui.json` beside it, as `ui.unreadable.json` or the +/// first free `ui.unreadable-N.json`, before the next save replaces it. A newer DesignCraft may +/// have written it. +fn keep_unreadable_prefs(path: &std::path::Path, bytes: &[u8]) { + use std::io::Write as _; + for n in 1..100u32 { + let name = if n == 1 { "ui.unreadable.json".to_string() } else { format!("ui.unreadable-{n}.json") }; + match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; + } + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, + } + } + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); +} + fn load_prefs(app: &mut DesignApp) { if no_prefs() { return; } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { - app.ui = ui; + match designcraft_ui_egui::UiState::read_preferences(&bytes) { + Ok(read) => { + app.ui = read.ui; + if !read.intact { + keep_unreadable_prefs(&p, &bytes); + } + } + Err(_) => keep_unreadable_prefs(&p, &bytes), + } + app.sync_keymap(); } // Engine preferences (Preferences dialog, favourites…) live beside the UI state. if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) @@ -168,7 +208,9 @@ fn save_prefs(app: &DesignApp) { } if let Some(p) = prefs_path() { let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + if !KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) + && let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) + { let _ = std::fs::write(&p, bytes); } if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { @@ -177,157 +219,8 @@ fn save_prefs(app: &DesignApp) { } } -/// One file-type row in an open dialog. `open_filters` is what `pick_open` applies. -struct OpenFilter { - name: &'static str, - extensions: &'static [&'static str], -} - -fn open_filters(purpose: &str) -> &'static [OpenFilter] { - match purpose { - "swatches" => &[OpenFilter { name: "Swatch Exchange (ASE)", extensions: &["ase"] }], - "script" => &[OpenFilter { name: "Script", extensions: &["dcscript", "txt", "json"] }], - "shortcuts" => &[OpenFilter { name: "Shortcut set (text)", extensions: &["txt"] }], - "icc" => &[OpenFilter { name: "ICC profile", extensions: &["icc", "icm"] }], - "book" => &[OpenFilter { name: "Book", extensions: &["dcbook"] }], - "xml" => &[OpenFilter { name: "XML", extensions: &["xml"] }], - "library" => &[OpenFilter { name: "Object Library", extensions: &["dclib"] }], - "relink" => &[OpenFilter { - name: "Graphics", - extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], - }], - "dataMerge" => &[OpenFilter { name: "Data source (CSV, TSV, text, Excel)", extensions: &["csv", "tsv", "tab", "txt", "xlsx"] }], - "place" => &[ - OpenFilter { - name: "Graphics and text", - extensions: &[ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "tif", - "tiff", - "bmp", - "psd", - "svg", - "pdf", - "ai", - "eps", - "txt", - "docx", - "rtf", - "md", - "xlsx", - "idml", - "designcraft", - "mp4", - "m4v", - "mov", - "webm", - "mp3", - "m4a", - "wav", - "ogg", - ], - }, - OpenFilter { - name: "Graphics", - extensions: &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "psd", "svg", "pdf", "ai", "eps"], - }, - OpenFilter { name: "Text (Word, RTF, plain, Excel)", extensions: &["docx", "rtf", "txt", "md", "xlsx"] }, - OpenFilter { name: "Video and sound", extensions: &["mp4", "m4v", "mov", "webm", "mp3", "m4a", "wav", "ogg"] }, - ], - _ => &[ - OpenFilter { name: "DesignCraft, IDML or InDesign", extensions: &["designcraft", "idml", "indd", "indt"] }, - OpenFilter { name: "DesignCraft", extensions: &["designcraft"] }, - OpenFilter { name: "InDesign Markup (IDML)", extensions: &["idml"] }, - OpenFilter { name: "InDesign document or template (INDD, INDT)", extensions: &["indd", "indt"] }, - // Files saved without an extension; `file.open` tells the formats apart by content. - OpenFilter { name: "All files", extensions: &[ALL_FILES] }, - ], - } -} - -/// The "All files" pattern. The macOS panel merges every filter into one list of allowed types, -/// which takes type identifiers as well as extensions: `public.data` admits any file. -const ALL_FILES: &str = if cfg!(target_os = "macos") { "public.data" } else { "*" }; - -/// The extension of the file name a save dialog suggests (`Brochure.designcraft`), if it has one. -/// The dialog filters on it, so the macOS and Windows dialogs add it to a name typed without it. -fn suggested_extension(name: &str) -> Option<&str> { - std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).filter(|e| !e.is_empty() && !e.contains(char::is_whitespace)) -} - -/// The file a save dialog's answer writes: `picked` with the suggested extension added when it has -/// none (Linux dialogs don't add it). A document always ends in `.designcraft`, as `file.save` -/// writes it. -fn picked_save_path(picked: std::path::PathBuf, ext: Option<&str>) -> std::path::PathBuf { - let Some(ext) = ext else { return picked }; - if ext.eq_ignore_ascii_case("designcraft") { - let p = picked.to_string_lossy(); - return designcraft_engine::cmd::with_document_extension(&p).into(); - } - if picked.extension().is_some() || picked.file_name().is_none() { - return picked; - } - let mut s = picked.into_os_string(); - s.push("."); - s.push(ext); - s.into() -} - -/// The folder a save dialog starts in and the name it suggests. A suggestion with a folder in -/// front (the document's, or the last one used) starts there while that folder still exists; -/// otherwise the system picks, as it did before. -fn save_dialog_start(suggested: &str) -> (Option, String) { - let path = std::path::Path::new(suggested); - let name = path.file_name().map_or_else(|| suggested.to_string(), |n| n.to_string_lossy().to_string()); - let folder = path.parent().filter(|f| !f.as_os_str().is_empty() && f.is_dir()).map(std::path::Path::to_path_buf); - (folder, name) -} - -/// The save dialog: the suggested name's extension as its filter, and the extension added to a -/// name typed without it. Adding it names another file than the one the dialog checked, so -/// replacing an existing one asks again. -fn pick_save(suggested: &str) -> Option { - let (folder, name) = save_dialog_start(suggested); - let name = name.as_str(); - let ext = suggested_extension(name); - let mut dialog = rfd::FileDialog::new().set_file_name(name); - if let Some(folder) = folder { - dialog = dialog.set_directory(folder); - } - if let Some(ext) = ext { - dialog = dialog.add_filter(ext.to_ascii_uppercase(), &[ext]); - } - let picked = dialog.save_file()?; - let path = picked_save_path(picked.clone(), ext); - if path != picked && path.exists() { - let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let answer = rfd::MessageDialog::new() - .set_level(rfd::MessageLevel::Warning) - .set_title("Replace File?") - .set_description(format!("“{file}” already exists. Do you want to replace it?")) - .set_buttons(rfd::MessageButtons::YesNo) - .show(); - if answer != rfd::MessageDialogResult::Yes { - return None; - } - } - Some(path.to_string_lossy().to_string()) -} - fn services() -> Services { Services { - pick_open: Some(Box::new(|purpose: &str| { - let mut dialog = rfd::FileDialog::new(); - for filter in open_filters(purpose) { - dialog = dialog.add_filter(filter.name, filter.extensions); - } - dialog.pick_file().map(|p| p.to_string_lossy().to_string()) - })), - pick_save: Some(Box::new(pick_save)), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| designcraft_engine::atomic::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))), clipboard_text: Some(Box::new(|| arboard::Clipboard::new().and_then(|mut c| c.get_text()).ok())), @@ -360,6 +253,11 @@ fn control_port_from(source: &str, value: Option) -> Option { } fn main() -> std::process::ExitCode { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::DESIGNCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // 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(); @@ -407,6 +305,9 @@ fn main() -> std::process::ExitCode { .with_fullsize_content_view(true) .with_titlebar_shown(false) .with_title_shown(false), + // DesignCraft's own FontDb supplies script-specific system fallbacks; egui's system-font + // provider would put its discovery ahead of that order and change line metrics. + system_font_fallback: false, ..Default::default() }; options.viewport = options.viewport.with_app_id(APP_ID); @@ -512,6 +413,7 @@ fn main() -> std::process::ExitCode { session.recovery_dir = profile::recovery_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); + app.set_startup_update_check(true); if let Some(line) = restarted.or_else(|| created.status_line()) { app.status(line); } @@ -573,13 +475,19 @@ fn main() -> std::process::ExitCode { #[cfg(test)] mod tests { #[test] - fn relink_open_dialog_lists_graphics() { - let filters = super::open_filters("relink"); - let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); - for ext in ["png", "jpg", "tif", "psd", "svg", "pdf", "ai", "eps"] { - assert!(exts.contains(&ext), "{ext} is missing from the relink dialog: {exts:?}"); - } - assert!(!exts.contains(&"designcraft") && !exts.contains(&"idml")); + fn an_unreadable_ui_json_is_kept_beside_it() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-ui-prefs-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("ui.json"); + super::keep_unreadable_prefs(&path, b"{\"docking\": {\"root\": {\"FutureLayout\": {}}}}"); + super::keep_unreadable_prefs(&path, b"not json"); + let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); + let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); + assert_eq!(second, b"not json"); + assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); } #[test] @@ -591,47 +499,6 @@ mod tests { assert_eq!(super::control_port_from("--control", Some(bad.into())), None, "{bad:?}"); } } - - #[test] - fn data_merge_open_dialog_lists_table_extensions() { - let filters = super::open_filters("dataMerge"); - let exts: Vec<&str> = filters.iter().flat_map(|filter| filter.extensions.iter().copied()).collect(); - for ext in ["csv", "tsv", "tab", "txt", "xlsx"] { - assert!(exts.contains(&ext), "{ext} is missing from the dataMerge dialog: {exts:?}"); - } - assert!(!exts.iter().any(|ext| *ext == "designcraft" || *ext == "idml"), "dataMerge must not fall through to the document filters: {exts:?}"); - let place = super::open_filters("place"); - assert!(place.len() > 1, "the place dialog keeps a filter for each kind of file"); - assert!(place.iter().any(|filter| filter.extensions.contains(&"png"))); - let documents = super::open_filters(""); - for ext in ["designcraft", "idml", "indd", "indt"] { - assert!(documents.first().is_some_and(|filter| filter.extensions.contains(&ext)), "{ext} is missing from the first Open filter"); - } - assert!(documents.last().is_some_and(|filter| filter.extensions == [super::ALL_FILES]), "files without an extension can be picked"); - } - - #[test] - fn save_dialog_answers_get_the_suggested_extension() { - use std::path::PathBuf; - let save = |picked: &str, name: &str| super::picked_save_path(PathBuf::from(picked), super::suggested_extension(name)); - assert_eq!(save("/d/Brochure", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); - assert_eq!(save("/d/Brochure.idml", "Untitled.designcraft"), PathBuf::from("/d/Brochure.idml.designcraft")); - assert_eq!(save("/d/Brochure.designcraft", "Untitled.designcraft"), PathBuf::from("/d/Brochure.designcraft")); - assert_eq!(save("/d/Brochure", "Untitled.pdf"), PathBuf::from("/d/Brochure.pdf")); - assert_eq!(save("/d/Brochure.txt", "Untitled.rtf"), PathBuf::from("/d/Brochure.txt"), "another export format is kept"); - assert_eq!(save("/d/Package", "Untitled 1.2 Folder"), PathBuf::from("/d/Package"), "a folder has no extension"); - } - - /// #1653: the save dialog opens in the folder the suggestion names, unless it is gone. - #[test] - fn save_dialog_starts_in_the_suggested_folder_while_it_exists() { - let folder = std::env::temp_dir(); - let suggested = folder.join("Brochure.pdf"); - assert_eq!(super::save_dialog_start(&suggested.to_string_lossy()), (Some(folder.clone()), "Brochure.pdf".to_string())); - let gone = folder.join(format!("designcraft-gone-{}", std::process::id())).join("Brochure.pdf"); - assert_eq!(super::save_dialog_start(&gone.to_string_lossy()), (None, "Brochure.pdf".to_string()), "a folder deleted since: the system's own"); - assert_eq!(super::save_dialog_start("Untitled 1.2 Folder"), (None, "Untitled 1.2 Folder".to_string()), "a bare name: the system's own"); - } } #[cfg(all(test, unix))] diff --git a/apps/designcraft/src/native_menu.rs b/apps/designcraft/src/native_menu.rs index 1ac9437d1..e127776ba 100644 --- a/apps/designcraft/src/native_menu.rs +++ b/apps/designcraft/src/native_menu.rs @@ -22,9 +22,10 @@ pub struct NativeMenu { items: HashMap, events: Receiver, last_refresh: f64, - /// The interface language the titles are in and whether CJK features are shown: the menu is - /// rebuilt when either changes. - shown_for: (String, bool), + /// The interface language the titles are in, whether CJK features are shown and whether the + /// Keyboard Shortcuts dialog records keys (the menu then has no key equivalents, so the keys + /// reach the dialog): the menu is rebuilt when any of them changes. + shown_for: (String, bool, bool), /// File › Open Recent, rebuilt when the list of recent files changes. recent: Option, /// The commands with key equivalents and the shortcut each was given: the menu is rebuilt @@ -65,10 +66,11 @@ impl NativeMenu { // muda keeps the first handler for the life of the process, so this channel serves every // menu bar built later (`build_bar`). let (tx, events) = channel(); - let repaint = ctx.clone(); + let notify = app.native_action_notifier(ctx.clone()); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { - let _ = tx.send(e); - repaint.request_repaint(); + if tx.send(e).is_ok() { + notify(); + } })); Self::build_bar(app, events) } @@ -85,7 +87,7 @@ impl NativeMenu { for entry in menus::MAC_APP_MENU { let _ = match *entry { AppMenuItem::Cmd { id, label, command } => { - let sc = shortcut(app, command).as_deref().and_then(accel); + let sc = shortcut(app, command).filter(|_| !app.shortcut_recording()).as_deref().and_then(accel); let item = MenuItem::with_id(id, menu_title(app, label), true, sc); let appended = app_menu.append(&item); items.insert(id.into(), (command.into(), Value::Null, Handle::Plain(item))); @@ -120,14 +122,18 @@ impl NativeMenu { app.native_menu = true; let keys: Vec<(String, Option)> = items.values().filter(|(_, p, _)| p.is_null()).map(|(id, _, _)| (id.clone(), shortcut(app, id))).collect(); - app.native_shortcuts = keys.iter().filter(|(_, sc)| sc.as_deref().and_then(accel).is_some()).map(|(id, _)| id.clone()).collect(); + let recording = app.shortcut_recording(); + app.native_shortcuts = keys + .iter() + .filter_map(|(id, sc)| sc.as_ref().filter(|sc| !recording && accel(sc).is_some()).map(|sc| (id.clone(), sc.clone()))) + .collect(); let recent = recent.map(|submenu| RecentMenu { submenu, shown: Vec::new(), ids: Vec::new(), separator: None }); Self { _menu: menu, items, events, last_refresh: 0.0, - shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app)), + shown_for: (app.ui.language.clone(), designcraft_ui_egui::cjk_features(app), app.shortcut_recording()), recent, keys, } @@ -170,15 +176,22 @@ impl NativeMenu { } pub fn poll(&mut self, app: &mut DesignApp, ctx: &egui::Context) { - while let Ok(ev) = self.events.try_recv() { - if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { - menus::activate_native(app, ctx, &cmd.clone(), ¶ms.clone()); + app.poll_native_actions(ctx, || { + loop { + let ev = self.events.try_recv().ok()?; + if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) { + return Some((cmd.clone(), params.clone())); + } } - } + }); // Interface Language and Show CJK Features (set in Preferences or by `app.language`, or put // back by Cancel): the system menu bar follows at once, like the panels (the new menu - // replaces the old one as the application's menu before that one is dropped). - if app.ui.language != self.shown_for.0 || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 { + // replaces the old one as the application's menu before that one is dropped). While the + // Keyboard Shortcuts dialog records, the bar has no key equivalents. + if app.ui.language != self.shown_for.0 + || designcraft_ui_egui::cjk_features(app) != self.shown_for.1 + || app.shortcut_recording() != self.shown_for.2 + { let events = std::mem::replace(&mut self.events, channel().1); *self = Self::build_bar(app, events); } @@ -236,7 +249,7 @@ fn build( Item::Cmd { label, id, params, .. } => { *counter += 1; let mid = format!("dc{counter}"); - let sc = if params.is_null() { shortcut(app, id).as_deref().and_then(accel) } else { None }; + let sc = if params.is_null() && !app.shortcut_recording() { shortcut(app, id).as_deref().and_then(accel) } else { None }; let label = menu_title(app, label); let handle = if menus::checked(app, id, params).is_some() { let c = CheckMenuItem::with_id(mid.clone(), label, true, false, sc); diff --git a/clippy.toml b/clippy.toml index 4b6c79f55..7b45b7aca 100644 --- a/clippy.toml +++ b/clippy.toml @@ -2,3 +2,9 @@ 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)" }, + { path = "rfd::FileDialog", reason = "use DesignApp::ask_open/ask_save (craft-dialogs): a blocking dialog inside the frame aborts the app on macOS" }, + { path = "rfd::MessageDialog", reason = "use craft-dialogs' ask_message: a blocking dialog inside the frame aborts the app on macOS" }, +] +cognitive-complexity-threshold = 30 diff --git a/contributors/people.toml b/contributors/people.toml index 2f44f93d4..79acd86b0 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -68,3 +68,6 @@ sync_github_name = false [people.4waymedia] real_name = "Paul Gemignani" + +[people.skylartaylor] +display_name = "skylartaylor" diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms.rs similarity index 100% rename from crates/color/src/cms/mod.rs rename to crates/color/src/cms.rs diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 6c19aabb1..6243101ef 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -24,16 +24,29 @@ impl GradientKind { } } -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientStop { /// 0..=1 along the gradient. pub offset: f32, + /// The display color. For a stop with a `swatch`, the swatch's color when it was last + /// resolved ([`GradientStop::swatch`]). pub color: Color, #[serde(default = "one")] pub opacity: f32, /// Midpoint to the next stop, 0.13..=0.87 (InDesign's diamond), default 0.5. #[serde(default = "half")] pub midpoint: f32, + /// The color swatch the stop takes its ink from: a spot or process color, a tint of one, + /// or `[Paper]`. Print export writes the stop in that swatch's inks; `None` is a plain color. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub swatch: Option, +} + +impl GradientStop { + /// A plain-color stop, opaque, with the midpoint halfway to the next stop. + pub fn new(offset: f32, color: Color) -> Self { + Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None } + } } fn one() -> f32 { @@ -52,13 +65,7 @@ pub struct Gradient { impl Default for Gradient { /// InDesign's default "White, Black" gradient. fn default() -> Self { - Self { - kind: GradientKind::Linear, - stops: vec![ - GradientStop { offset: 0.0, color: Color::WHITE, opacity: 1.0, midpoint: 0.5 }, - GradientStop { offset: 1.0, color: Color::BLACK, opacity: 1.0, midpoint: 0.5 }, - ], - } + Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] } } } @@ -238,6 +245,16 @@ mod tests { assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. })); } + #[test] + fn saved_stops_without_a_swatch_are_plain_colors() { + let old = r#"{"offset": 0.0, "color": {"model": "cmyk", "c": 0.0, "m": 0.3, "y": 0.9, "k": 0.1}, "opacity": 1.0, "midpoint": 0.5}"#; + let stop: GradientStop = serde_json::from_str(old).unwrap(); + assert_eq!(stop, GradientStop::new(0.0, Color::cmyk(0.0, 0.3, 0.9, 0.1))); + assert!(!serde_json::to_string(&stop).unwrap().contains("swatch"), "plain stops save as before"); + let on = GradientStop { swatch: Some("Gold".into()), ..stop }; + assert_eq!(serde_json::from_str::(&serde_json::to_string(&on).unwrap()).unwrap(), on); + } + #[test] fn midpoint_shifts() { let mut g = Gradient::default(); @@ -272,7 +289,7 @@ mod tests { // With several stops, the reversed gradient is the original mirrored. let mut g = Gradient::default(); g.stops[0].midpoint = 0.3; - g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8 }); + g.stops.insert(1, GradientStop { offset: 0.4, color: Color::rgb(1.0, 0.0, 0.0), opacity: 0.5, midpoint: 0.8, swatch: None }); let original = g.clone(); g.reverse(); for t in [0.1, 0.3, 0.5, 0.7, 0.9] { diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 85b3a0dc2..9b453ff0a 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -48,6 +48,9 @@ pub struct Spacing { pub hyph_zone: f64, /// Optical margin alignment: punctuation hangs outside the measure (counted as extra width). pub optical: bool, + /// The type size the hang amounts are set for (Story ▸ Optical Margin Alignment's size); + /// `None`: each glyph's own size, so the hang is [`hang`]'s proportion of its advance. + pub optical_size: Option, /// Korean text breaks between syllables instead of at spaces. pub korean_char_breaks: bool, /// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only. @@ -72,6 +75,7 @@ impl Default for Spacing { ragged_stretch: 24.0, hyph_zone: 0.0, optical: false, + optical_size: None, korean_char_breaks: false, kinsoku_priority: 0, } @@ -318,16 +322,32 @@ pub fn hang(c: char) -> (f64, f64) { } } -fn hang_left(g: &Glyph) -> f64 { - hang(g.ch).0 * g.adv +/// How much more than [`hang`]'s proportions `g` hangs: the hang is set for +/// [`Spacing::optical_size`] and grows with it, relative to the glyph's own size. +pub(crate) fn optical_scale(g: &Glyph, sp: &Spacing) -> f64 { + match sp.optical_size { + Some(s) if s.is_finite() && g.size.is_finite() && g.size > 0.0 => s / g.size, + _ => 1.0, + } +} + +/// Optical hang of `g` at the start of a line (0 without optical margin alignment). +pub(crate) fn hang_left(g: &Glyph, sp: &Spacing) -> f64 { + if sp.optical { hang(g.ch).0 * g.adv * optical_scale(g, sp) } else { 0.0 } +} + +/// Optical hang of `g` at the end of a line (0 without optical margin alignment). +pub(crate) fn hang_right(g: &Glyph, sp: &Spacing) -> f64 { + if sp.optical { hang(g.ch).1 * g.adv * optical_scale(g, sp) } else { 0.0 } } fn right_hang(g: &Glyph, sp: &Spacing) -> f64 { - g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end + g.cjk_hang.max(hang_right(g, sp)) - g.moji.end } -fn hang_right(g: &Glyph) -> f64 { - hang(g.ch).1 * g.adv +/// The part of a hyphen after `g` (of width `hw`) that hangs past the line's end. +fn hyphen_hang(g: &Glyph, hw: f64, sp: &Spacing) -> f64 { + if sp.optical { hw * hang('-').1 * optical_scale(g, sp) } else { 0.0 } } /// Advance of the hyphen glyph that would follow `g` (cached per face). @@ -428,15 +448,13 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut p = Item::penalty(hw, sp.hyphen_penalty, true); p.auto = true; p.zone_from = last_space; - if sp.optical { - p.hang = hw * hang('-').1; - } + p.hang = hyphen_hang(g, hw, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); continue; } let (st, sh) = sp.box_elastic(g); let mut bx = Item::boxed(g.adv, st, sh); - bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start; + bx.hang = hang_left(g, sp) - g.moji.start; bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3); it.push(bx); ig.push(i); @@ -457,9 +475,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S let mut p = Item::penalty(hw, sp.hyphen_penalty, true); p.auto = true; p.zone_from = last_space; - if sp.optical { - p.hang = hw * hang('-').1; - } + p.hang = hyphen_hang(g, hw, sp); push_inword(&mut it, &mut ig, p, i + 1, sp); } else if emergency_after.get(i) == Some(&true) { let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false); @@ -510,7 +526,9 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & (items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp); } for tolerance in [2.5, 12.0, f64::INFINITY] { - if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) { + if let Some(b) = + kp_pass(PassInput { items: &items, item_glyphs: &ig, glyphs, spacing: sp, width, tolerance, emergency: tolerance.is_infinite() }) + { return b; } } @@ -587,22 +605,78 @@ fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) /// Upper bound on the hyphen-count states tracked per breakpoint. const MAX_HYPHEN_STATES: usize = 8; -#[allow(clippy::too_many_arguments)] -fn kp_pass( - items: &[Item], - ig: &[usize], - glyphs: &[Glyph], - sp: &Spacing, - width: &dyn Fn(usize) -> f64, - tol: f64, +struct PassInput<'a> { + items: &'a [Item], + item_glyphs: &'a [usize], + glyphs: &'a [Glyph], + spacing: &'a Spacing, + width: &'a dyn Fn(usize) -> f64, + tolerance: f64, emergency: bool, -) -> Option> { - let m = items.len(); - // Prefix sums (before item i). - let mut sw = vec![0.0; m + 1]; - let mut sy = vec![[0.0; 3]; m + 1]; - let mut sz = vec![[0.0; 3]; m + 1]; - let mut so = vec![0.0; m + 1]; +} + +/// Enforce consecutive-hyphen limits and the unjustified hyphenation zone. +fn hyphen_allowed(it: Item, n: Node, sp: &Spacing, widths: &[f64], items: &[Item], target: f64) -> bool { + let limit = sp.hyphen_limit as usize; + if limit > 0 && n.hyphens as usize >= limit { + return false; + } + if it.auto && !sp.justify && sp.hyph_zone > 0.0 && it.zone_from != NONE && it.zone_from > n.pos { + let before = widths[it.zone_from] - n.tw - items[it.zone_from].hang; + if target - before < sp.hyph_zone { + return false; + } + } + true +} + +/// Fitness class and cumulative penalty for a candidate line break. +fn break_demerits(n: Node, r: f64, pp: f64, flagged: bool, priority: u8) -> (usize, f64) { + let preference = match priority { + 1 if r > 0.0 => 4.0, + 2 if r < 0.0 => 4.0, + _ => 1.0, + }; + let bad = preference * 100.0 * r.abs().powi(3); + let lp = 10.0 + bad; + let mut d = if pp >= 0.0 { + lp * lp + pp * pp + } else if pp > -INF { + lp * lp - pp * pp + } else { + lp * lp + }; + if flagged && n.flagged { + d += 3000.0; + } + let fit = if r < -0.5 { + 0 + } else if r <= 0.5 { + 1 + } else if r <= 1.0 { + 2 + } else { + 3 + }; + if (fit as i32 - n.fitness as i32).abs() > 1 { + d += 3000.0; + } + (fit, n.demerits + d) +} + +/// Width, stretch, shrink and overhang totals before each item. +struct PrefixSums { + sw: Vec, + sy: Vec<[f64; 3]>, + sz: Vec<[f64; 3]>, + so: Vec, +} + +fn prefix_sums(items: &[Item]) -> PrefixSums { + let mut sw = vec![0.0; items.len() + 1]; + let mut sy = vec![[0.0; 3]; items.len() + 1]; + let mut sz = vec![[0.0; 3]; items.len() + 1]; + let mut so = vec![0.0; items.len() + 1]; for (i, it) in items.iter().enumerate() { let (w, y, z) = match it.kind { Kind::Box | Kind::Glue => (it.w, it.st, it.sh), @@ -615,6 +689,13 @@ fn kp_pass( sz[i + 1][t] = sz[i][t] + z[t]; } } + PrefixSums { sw, sy, sz, so } +} + +fn kp_pass(input: PassInput<'_>) -> Option> { + let PassInput { items, item_glyphs, glyphs, spacing: sp, width, tolerance: tol, emergency } = input; + let m = items.len(); + let PrefixSums { sw, sy, sz, so } = prefix_sums(items); let first_box = items.iter().find(|i| i.kind == Kind::Box); let first_hang = first_box.map_or(0.0, |b| b.hang); let first_elastic = first_box.map_or([0.0; 2], |b| b.start_elastic); @@ -679,51 +760,11 @@ fn kp_pass( if !feasible || (r > tol && !forced) { continue; } - if flagged { - // Hyphen limit: a hard constraint. - if limit > 0 && n.hyphens as usize >= limit { - continue; - } - // Hyphenation zone (unjustified): break before the word instead when that leaves - // less than the zone at the line end. - if it.auto && !sp.justify && sp.hyph_zone > 0.0 && it.zone_from != NONE && it.zone_from > n.pos { - let before = sw[it.zone_from] - n.tw - items[it.zone_from].hang; - if target - before < sp.hyph_zone { - continue; - } - } - } - let preference = match sp.kinsoku_priority { - 1 if r > 0.0 => 4.0, - 2 if r < 0.0 => 4.0, - _ => 1.0, - }; - let bad = preference * 100.0 * r.abs().powi(3); - let lp = 10.0 + bad; - let mut d = if pp >= 0.0 { - lp * lp + pp * pp - } else if pp > -INF { - lp * lp - pp * pp - } else { - lp * lp - }; - if flagged && n.flagged { - d += 3000.0; - } - let fit = if r < -0.5 { - 0 - } else if r <= 0.5 { - 1 - } else if r <= 1.0 { - 2 - } else { - 3 - }; - if (fit as i32 - n.fitness as i32).abs() > 1 { - d += 3000.0; + if flagged && !hyphen_allowed(it, n, sp, &sw, items, target) { + continue; } + let (fit, total) = break_demerits(n, r, pp, flagged, sp.kinsoku_priority); let hs = if flagged { (n.hyphens as usize + 1).min(states - 1) } else { 0 }; - let total = n.demerits + d; let slot = &mut best[fit * states + hs]; if slot.is_none_or(|(bd, _)| total < bd) { *slot = Some((total, a)); @@ -804,7 +845,7 @@ fn kp_pass( cur = arena[c].prev; } chain.reverse(); - Some(breaks_from_positions(items, ig, glyphs, &chain)) + Some(breaks_from_positions(items, item_glyphs, glyphs, &chain)) } fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain: &[usize]) -> Vec { @@ -876,7 +917,7 @@ pub fn greedy( let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit; while start < n { let w = width(line).max(1.0); - let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start; + let mut x = -hang_left(&glyphs[start], sp) + glyphs[start].moji.start; let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1]; let mut last_natural: Option<(usize, bool)> = None; let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen) @@ -939,7 +980,8 @@ pub fn greedy( last_natural = last_ok; } } else if hyph_after[i] && may_hyphenate(hyphens) { - let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 }; + let hw = hyphen_width(g, &mut hy_cache); + let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0); if x + hy <= w + shrink + FIT_EPS { last_ok = Some((i + 1, true)); } diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 70e22710d..66122ccf4 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -3,7 +3,8 @@ //! An entry is valid while the inputs it was composed from are the *same allocations*: the story //! `Arc` (and its revision), the styles `Arc`, the frame items' `Arc`s and the `Arc`s of every //! wrapping item on the frames' spreads, and the same for every story whose anchored objects wrap -//! its text ([`crate::wrap::neighbours`]). Entries hold clones of those `Arc`s, so the pointers +//! its text ([`crate::wrap::neighbours`]). An anchored frame's entry also records where its host +//! story's composition places it. Entries hold clones of those `Arc`s, so the pointers //! can't be reused by other allocations while the entry lives. use std::collections::HashMap; @@ -99,11 +100,12 @@ impl Cache { /// [`Cache::get`] for any [`ShownOn`]. pub fn get_on(&self, doc: &Document, sid: StoryId, on: ShownOn) -> Arc { - self.get_with(doc, sid, on, true) + self.get_with(doc, sid, on, true, 0) } - /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set. - fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool) -> Arc { + /// [`Cache::get_on`], wrapped by other stories' anchored objects only when `cross` is set, for + /// a story `depth` hosts down from the story asked for. + fn get_with(&self, doc: &Document, sid: StoryId, on: ShownOn, cross: bool, depth: usize) -> Arc { let Some(story) = doc.stories.get(&sid) else { return Arc::new(ComposedStory { story: sid, ..Default::default() }) }; let (mut sig, mut keep_items) = signature(doc, story); let neighbours = if cross { crate::wrap::neighbours(doc, sid) } else { Vec::new() }; @@ -117,6 +119,24 @@ impl Cache { keep_stories.push(ns.clone()); } } + // Anchored frames compose where their host story places them, so the host is composed + // first and the placements are part of the signature. + let placed = if depth < crate::placed::MAX_DEPTH { + crate::placed::placements(doc, sid, &mut |host| self.get_with(doc, host, ShownOn::Own, true, depth + 1)) + } else { + Vec::new() + }; + for (id, p) in &placed { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (id, p.spread, p.xf.as_coeffs().map(f64::to_bits)).hash(&mut h); + // The placed spread's pages give the frame its page, side and page rect. + for pg in doc.spread(p.spread).map_or(&[][..], |sp| &sp.pages[..]) { + let m = &pg.margins; + (pg.side as u8, [pg.x, pg.width, pg.height, m.top, m.bottom, m.inside, m.outside].map(f64::to_bits)).hash(&mut h); + } + sig.push(h.finish() as usize); + } let key = (sid, on, !neighbours.is_empty()); // The key holds the page's index, which a renumbering doesn't change: its name (section // start number, style, prefix) is part of the signature. @@ -209,11 +229,11 @@ impl Cache { // Parent-page items are composed per page: markers show its name, variables need its index. let page = page.filter(|_| with_vars); let running = running.then(|| self.running_index(doc)); - let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n)).collect(); + let others: Vec> = neighbours.iter().map(|n| self.neighbour(doc, *n, depth)).collect(); let out = Arc::new(crate::wrap::compose_among( doc, sid, - &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)) }, + &ComposeOptions { page_name, section_marker, page, running, label: None, xrefs: with_xrefs.then(|| self.xref_index(doc)), placed }, &others.iter().map(|o| &**o).collect::>(), )); let mut g = self.map.lock().unwrap_or_else(PoisonError::into_inner); @@ -295,13 +315,13 @@ impl Cache { /// Story `sid` composed for the wrap of its anchored objects in other stories: without other /// stories' anchored objects (no cycles), and outside the cache when it needs the document-wide /// indexes (they compose every story). - fn neighbour(&self, doc: &Document, sid: StoryId) -> Arc { + fn neighbour(&self, doc: &Document, sid: StoryId, depth: usize) -> Arc { let indexed = doc.stories.get(&sid).is_some_and(|st| crate::xref::has_xrefs(st) || (crate::vars::has_vars(st) && crate::vars::has_running(doc, st))); if indexed { Arc::new(crate::wrap::compose_among(doc, sid, &ComposeOptions::default(), &[])) } else { - self.get_with(doc, sid, ShownOn::Own, false) + self.get_with(doc, sid, ShownOn::Own, false, depth) } } diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 1d7e0e6a9..6f9e8f9e3 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -23,6 +23,13 @@ use designcraft_doc::story; pub const MAX_PATTERN: usize = 4096; /// Backtracking steps one search may take before it gives up. pub const BACKTRACK_LIMIT: usize = 200_000; +/// Matches one GREP style may contribute to one paragraph. +/// +/// [`BACKTRACK_LIMIT`] bounds a single search, not a scan: `find_all` pays it again for every +/// match, so a pattern tuned just under the limit cost `BACKTRACK_LIMIT × matches` per paragraph +/// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and +/// a scan that reaches it stops with what it has. +pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096; /// Compiled program size cap (bytes) for the underlying regex engine. const SIZE_LIMIT: usize = 4 << 20; @@ -378,6 +385,17 @@ impl Grep { self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect() } + /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it + /// doesn't count toward a GREP style's cap. + pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { + self.re + .find_iter(text) + .filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty())) + .take(limit) + .map(|m| m.map(|m| m.range()).map_err(run_err)) + .collect() + } + /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see /// [`translate_replacement`]). Stops after the first match when `first_only`. pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result, String)>, GrepError> { diff --git a/crates/compose/src/hyphen/mod.rs b/crates/compose/src/hyphen.rs similarity index 99% rename from crates/compose/src/hyphen/mod.rs rename to crates/compose/src/hyphen.rs index 57b75d63b..3f07f4b40 100644 --- a/crates/compose/src/hyphen/mod.rs +++ b/crates/compose/src/hyphen.rs @@ -26,8 +26,8 @@ use std::sync::OnceLock; pub use dict::Dictionary; pub use patterns::Patterns; -static EN_US_DIC: &[u8] = include_bytes!("../../../../assets/hyphenation/en-us.dic"); -static EN_US_PAT: &str = include_str!("../../../../assets/hyphenation/en-us.pat"); +static EN_US_DIC: &[u8] = include_bytes!("../../../assets/hyphenation/en-us.dic"); +static EN_US_PAT: &str = include_str!("../../../assets/hyphenation/en-us.pat"); impl Dictionary { /// The bundled US English dictionary (decoded on first use). diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e80121543..88becd85f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -23,6 +23,7 @@ pub mod optical; mod outline; mod overlay; mod parabox; +mod placed; mod ruby; pub mod shape; pub mod shatai; @@ -46,10 +47,11 @@ use designcraft_geom::{Affine, Point, Rect}; use crate::breaker::{Break, Spacing}; pub use crate::cache::{Cache, ShownOn}; -use crate::notes::Notes; +use crate::notes::{NoteColumn, Notes}; pub use crate::notes::{find_note, hit_note, hit_note_with, note_caret}; pub use crate::outline::FrameOutline; use crate::parabox::ParaBox; +pub use crate::placed::Placement; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{BorderHit, PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_border, hit_cell, hit_cell_with}; @@ -627,6 +629,9 @@ pub struct ComposeOptions { pub label: Option, /// Anchor pages for cross-references (needed when the story contains cross-references). pub xrefs: Option>, + /// Where the story's anchored frames sit on their spreads; an anchored frame not listed is + /// composed where it is stored, on no page. + pub placed: Vec<(ItemId, Placement)>, } /// Nesting depth past which wrap obstacles inside groups are not collected. @@ -657,40 +662,84 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag } } +/// Adds to `exclusions` the objects on `sp` that wrap the text of frame `item`. Only objects that +/// reach the frame's text area wrap its text: one elsewhere on the spread would otherwise cost a +/// narrow column its room (the 1.5 em minimum segment of `free_slots` applies once an object cuts +/// the column). +fn frame_wrap_exclusions(doc: &Document, sp: &designcraft_doc::Spread, item: &Item, inv: Affine, exclusions: &mut Vec) { + let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); + for other in &sp.items { + if doc.layer(other.layer).is_some_and(|l| !l.visible) { + continue; + } + wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, exclusions); + } + let a = item.text_area(); + exclusions.retain(|e| { + let r = e.rect; + r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 + }); +} + +/// The text area and options a frame composes into. On a path, one line as long as the path (from +/// the start offset). +fn frame_text_area(doc: &Document, st: &Story, item: &Item, tf: &designcraft_doc::TextFrame) -> (Rect, TextFrameOptions) { + match &tf.options.path { + Some(pt) => { + let len = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), false).length(); + let size = st + .paras + .first() + .map(|p| doc.styles.resolve_para(p).1) + .map(|base| st.runs().map(|(_, f)| doc.styles.resolve_char(&base, f).size).fold(base.size, f64::max)) + .unwrap_or(12.0); + let opts = TextFrameOptions { + columns: 1, + inset: [0.0; 4], + first_baseline: designcraft_doc::FirstBaseline::Ascent, + // Keep the path identity: its synthetic area uses full line extents. + ..tf.options.clone() + }; + let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) + } + None => (item.text_area(), tf.options.clone()), + } +} + /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { + frame_specs_placed(doc, sid, &[]) +} + +/// [`frame_specs`] with anchored frames where their host story placed them (`placed`). +pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId, Placement)]) -> Vec { let Some(st) = doc.story(sid) else { return vec![] }; let mut out = Vec::with_capacity(st.frames.len()); for &fid in &st.frames { let loc = doc.find(fid); let Some(item) = loc.as_ref().and_then(|l| doc.item_at(l)).or_else(|| doc.anchored_item(fid)) else { continue }; let Some(tf) = item.text_frame() else { continue }; - let parent = loc.as_ref().map(|l| doc.parent_xf(l)).unwrap_or(designcraft_geom::Affine::IDENTITY); + // An anchored frame takes its page, page side, page rect and grid from where it is placed. + let placement = if loc.is_none() { placed.iter().find(|(id, _)| *id == fid).map(|(_, p)| *p) } else { None }; + let parent = match (&loc, placement) { + (Some(l), _) => doc.parent_xf(l), + (None, Some(p)) => p.xf, + (None, None) => Affine::IDENTITY, + }; let xf = parent * item.xf; // Where the frame is on the spread (a group's transform included), to find its page. let center_x = parent.transform_rect_bbox(item.bounds()).center().x; let inv = xf.inverse(); let mut exclusions = Vec::new(); - let spread_ref = loc.as_ref().map(|l| l.spread); + let spread_ref = loc.as_ref().map(|l| l.spread).or(placement.map(|p| p.spread)); let spread = spread_ref.and_then(|r| doc.spread(r)); + // Page items wrap the text of frames on the spread, not of anchored frames. if !tf.options.ignore_wrap + && loc.is_some() && let Some(sp) = spread { - let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); - for other in &sp.items { - if doc.layer(other.layer).is_some_and(|l| !l.visible) { - continue; - } - wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, &mut exclusions); - } - // Only objects that reach the frame's text area wrap its text: one elsewhere on the - // spread would otherwise cost a narrow column its room (the 1.5 em minimum segment of - // `free_slots` applies once an object cuts the column). - let a = item.text_area(); - exclusions.retain(|e| { - let r = e.rect; - r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 - }); + frame_wrap_exclusions(doc, sp, item, inv, &mut exclusions); } let (page_name, page, side) = match (spread_ref, spread) { (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { @@ -713,32 +762,26 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { let under = spread.and_then(|sp| sp.page_at_x(center_x).and_then(|pi| sp.pages.get(pi))); let page_rect = under.map(|pg| (inv.transform_rect_bbox(pg.bounds()), inv.transform_rect_bbox(pg.margin_rect()))); let g = &doc.settings.baseline_grid; - let (start, inc) = tf.options.baseline_grid.map_or((under.map_or(g.start, |pg| g.origin(pg)), g.increment), |f| (f.start, f.increment)); // Grid lines are at spread y = start + n·inc (page tops are y = 0); map into inner space (translation only). let ty = xf.translation().y; + let (start, inc) = match tf.options.baseline_grid { + None => (under.map_or(g.start, |pg| g.origin(pg)), g.increment), + Some(f) => { + use designcraft_doc::FrameGridRelative as R; + // The frame's own edges are in inner space; `ty` takes them to spread y as it takes + // the grid back, so a frame-relative grid keeps its place in a turned frame. + let from = match f.relative_to { + R::TopOfPage => 0.0, + R::TopMargin => under.map_or(0.0, |pg| pg.margins.top), + R::TopOfFrame => ty + item.inner_bounds().y0, + R::TopInset => ty + item.text_area().y0, + }; + (from + f.start, f.increment) + } + }; let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc)); // Type on a path: one line as long as the path (from the start offset). - let (area, opts) = match &tf.options.path { - Some(pt) => { - let len = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), false).length(); - let size = st - .paras - .first() - .map(|p| doc.styles.resolve_para(p).1) - .map(|base| st.runs().map(|(_, f)| doc.styles.resolve_char(&base, f).size).fold(base.size, f64::max)) - .unwrap_or(12.0); - let opts = TextFrameOptions { - columns: 1, - inset: [0.0; 4], - first_baseline: designcraft_doc::FirstBaseline::Ascent, - // Keep the path identity: its synthetic area uses full line extents. - ..tf.options.clone() - }; - let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); - (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) - } - None => (item.text_area(), tf.options.clone()), - }; + let (area, opts) = frame_text_area(doc, st, item, tf); // A non-rectangular frame keeps the top inset inward from its outline; its rectangular text // area (all four insets) still bounds the lines. let outline = match &tf.options.path { @@ -783,10 +826,23 @@ pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { /// Compose a story of `doc` through its frames, with the text wrap of page items and of anchored /// objects ([`wrap`]). +/// Anchored frames are composed where their host story places them (unless `opts` places them). pub fn compose_story(doc: &Document, sid: StoryId, opts: &ComposeOptions) -> ComposedStory { + compose_story_at(doc, sid, opts, 0) +} + +/// [`compose_story`] for a story `depth` hosts down from the story asked for. +fn compose_story_at(doc: &Document, sid: StoryId, opts: &ComposeOptions, depth: usize) -> ComposedStory { let others: Vec = wrap::neighbours(doc, sid).into_iter().map(|o| wrap::compose_among(doc, o, &ComposeOptions::default(), &[])).collect(); - wrap::compose_among(doc, sid, opts, &others.iter().collect::>()) + let others: Vec<&ComposedStory> = others.iter().collect(); + if opts.placed.is_empty() && depth < placed::MAX_DEPTH { + let placed = placed::placements(doc, sid, &mut |host| Arc::new(compose_story_at(doc, host, &ComposeOptions::default(), depth + 1))); + if !placed.is_empty() { + return wrap::compose_among(doc, sid, &ComposeOptions { placed, ..opts.clone() }, &others); + } + } + wrap::compose_among(doc, sid, opts, &others) } const DEFAULT_TAB: f64 = 36.0; @@ -796,26 +852,1780 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo compose_with_db(doc, story, frames, opts, FontDb::global()) } +struct SubstitutionInput<'a> { + doc: &'a Document, + story: &'a Story, + range: Range, + props: &'a designcraft_doc::ParaProps, + frame: Option<&'a FrameSpec>, + opts: &'a ComposeOptions, + var_story: bool, + var_cache: &'a mut HashMap, Arc>>, + notes: &'a mut Notes, +} + +fn paragraph_substitution(input: SubstitutionInput<'_>) -> SubstCtx { + let SubstitutionInput { doc, story, range: prange, props: pp, frame: cur_frame, opts, var_story, var_cache, notes } = input; + let var_values = if var_story { + let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); + var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone() + } else { + Default::default() + }; + let mut sub = SubstCtx { + page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())), + section_marker: opts.section_marker.clone().or_else(|| cur_frame.and_then(|f| f.section_marker.clone())), + vars: var_values, + hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), + vertical: cur_frame.is_some_and(|f| f.vertical), + auto_tcy: pp.auto_tcy.min(shape::AUTO_TCY_MAX), + auto_tcy_roman: pp.auto_tcy_include_roman, + rotate_roman: pp.rotate_roman, + ..Default::default() + }; + if !story.endnotes.is_empty() { + // Endnote reference numbers continue through the document. + let first = doc.endnote_start(story.id).saturating_add(story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32); + for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() { + sub.notes.insert(prange.start + i, doc.endnote_options.label(first.saturating_add(k as u32))); + } + } + if !story.objects.is_empty() { + let before = story.text[..prange.start].matches(designcraft_doc::OBJECT_MARK).count(); + for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::OBJECT_MARK).enumerate() { + let Some(o) = story.objects.get(before + k) else { continue }; + let (w, h) = o.size(); + let (y_offset, space, custom) = match &o.position { + designcraft_doc::AnchorPosition::Inline { y_offset } => (Some(*y_offset), 0.0, false), + designcraft_doc::AnchorPosition::AboveLine { space_before, space_after, .. } => (None, space_before + space_after, false), + designcraft_doc::AnchorPosition::Custom { .. } => (Some(0.0), 0.0, true), + }; + sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space, custom }); + } + } + if !story.xrefs.is_empty() { + sub.xrefs = xref::texts_in(doc, story, prange.clone(), opts.xrefs.as_deref()); + } + if notes.active() { + notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub); + } + sub +} + +fn rule_color(r: &designcraft_doc::Rule, text: &(String, f32)) -> (String, f32) { + if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) } +} + +fn forced_break_start(forced: bool, has_more_lines: bool, break_char: Option) -> Option { + if forced && has_more_lines { break_char.and_then(break_start) } else { None } +} + +fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range, column: Rect, baseline: f64) -> bool { + frame.exclusions.iter().any(|ex| { + ex.after.is_some_and(|a| range.contains(&a)) + && ex.rect.x0 < column.x1 + && ex.rect.x1 > column.x0 + && ex.rect.y1 > baseline + && ex.rect.y0 < column.y1 + }) +} + +fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { + (0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| { + let range = ranges.get(p)?; + let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?; + let start = break_start(character)?; + let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); + let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8())); + let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); + (!gone).then_some(start) + }) +} + +fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool]) -> Vec> { + let mut counters: std::collections::HashMap = std::collections::HashMap::new(); + story + .paras + .iter() + .zip(ranges) + .zip(deleted) + .map(|((paragraph, range), is_deleted)| { + let (props, _) = doc.styles.resolve_para(paragraph); + if props.list_type != designcraft_doc::ListType::Numbers || props.list_name.is_empty() || range.is_empty() || *is_deleted { + return None; + } + let counter = counters.entry(props.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &props.list_name)); + counter.advance(&props); + Some(props.number_label_in(counter)) + }) + .collect() +} + +fn deleted_paragraph_info(info: &[ParaInfo], cursor: &Cursor) -> ParaInfo { + info.last().map_or( + ParaInfo { + start: (cursor.fi, cursor.col), + at_top: cursor.last_baseline.is_none(), + end: (cursor.fi, cursor.col), + lines: 0, + lines_in_end_col: 0, + keep_with_next: 0, + keep_together: false, + keep_all: false, + keep_first: 1, + keep_last: 1, + }, + |previous| ParaInfo { lines_in_end_col: 0, ..previous.clone() }, + ) +} + +fn frame_columns_and_text(frames: &[FrameSpec], direction: designcraft_doc::TextDirection) -> (Vec>, Vec) { + let columns: Vec> = frames + .iter() + .map(|frame| { + let mut frame_columns = frame.columns(); + if direction == designcraft_doc::TextDirection::RightToLeft && !frame.vertical { + frame_columns.reverse(); + } + frame_columns + }) + .collect(); + let frame_text = frames + .iter() + .zip(&columns) + .map(|(frame, columns)| FrameText { frame: frame.id, vertical: frame.vertical, columns: columns.clone(), ..Default::default() }) + .collect(); + (columns, frame_text) +} + +struct ParagraphProgressInput<'a> { + paragraph: usize, + count: usize, + balance_frames: usize, + trial: &'a mut Option, + limits: &'a mut Rc>, + cursor: &'a Cursor, + columns: &'a [Vec], + frames: &'a [FrameSpec], + out: &'a ComposedStory, + may_retry: bool, +} + +enum ParagraphProgress { + Continue, + Restart(usize), + Complete, +} + +fn paragraph_progress(input: ParagraphProgressInput<'_>) -> ParagraphProgress { + let ParagraphProgressInput { paragraph, count, balance_frames, trial, limits, cursor, columns, frames, out, may_retry } = input; + if paragraph < count { + return ParagraphProgress::Continue; + } + if balance_frames > 0 + && let Some(j) = balance_end(trial, limits, cursor, columns, frames, out, count, may_retry) + { + return ParagraphProgress::Restart(j); + } + ParagraphProgress::Complete +} + +struct TableParagraphInput<'a> { + doc: &'a Document, + table: &'a designcraft_doc::Table, + anchor: usize, + paragraph: usize, + range_start: usize, + props: &'a designcraft_doc::ParaProps, + start: StartParagraph, + frames: &'a [FrameSpec], + columns: &'a [Vec], + cursor: &'a mut Cursor, + out: &'a mut ComposedStory, + opts: &'a ComposeOptions, + trial: &'a mut Option, + limits: &'a mut Rc>, + balance_runs: &'a mut usize, + balance_budget: usize, + snaps: &'a [Snapshot], + note_snaps: &'a [(u32, Option, usize)], + notes: &'a mut Notes, + list_counter: &'a mut ListCounter, + force_col: &'a mut [bool], + line_cap: &'a mut [Option], + no_hyph: &'a mut [Vec], + info: &'a mut Vec, + boxes: &'a mut [Option], +} + +enum TableParagraphResult { + Placed, + Restart(usize), + Overset, +} + +fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult { + let TableParagraphInput { + doc, + table, + anchor, + paragraph, + range_start, + props, + start, + frames, + columns, + cursor, + out, + opts, + trial, + limits, + balance_runs, + balance_budget, + snaps, + note_snaps, + notes, + list_counter, + force_col, + line_cap, + no_hyph, + info, + boxes, + } = input; + if cursor.last_baseline.is_some() { + cursor.start(start, columns, frames, doc); + } else { + cursor.start_at_top(start, frames, doc); + } + let at_top = cursor.last_baseline.is_none(); + let start_at = (cursor.fi, cursor.col); + if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) { + if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) { + *balance_runs += 1; + rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph); + return TableParagraphResult::Restart(j); + } + out.overset_at = Some(range_start); + return TableParagraphResult::Overset; + } + info.push(ParaInfo { + start: start_at, + at_top, + end: (cursor.fi, cursor.col), + lines: 1, + lines_in_end_col: 1, + keep_with_next: 0, + keep_together: false, + keep_all: false, + keep_first: 1, + keep_last: 1, + }); + *list_counter = ListCounter::default(); + boxes[paragraph] = None; + cursor.pending += props.space_after; + TableParagraphResult::Placed +} + +struct NestedLineStyleInput<'a, 'b, 'font> { + db: &'a ScopedFonts<'font>, + doc: &'a Document, + story: &'a Story, + range: Range, + props: &'a designcraft_doc::ParaProps, + para_chars: &'a designcraft_doc::CharProps, + drop_end: usize, + base_leading: f64, + base_cap: f64, + drop_grid: Option, + drop_align: bool, + drop_tabs: (&'a [designcraft_doc::TabStop], f64), + cursor: &'a Cursor, + frames: &'a [FrameSpec], + columns: &'a [Vec], + exceptions: &'a HashMap>, + suppressed_hyphens: &'a [usize], + subst: &'a SubstCtx, + style_table: &'a mut StyleTable<'b>, + env: shape::TypeEnv, + shaped: &'a mut shape::ShapedPara, +} + +fn apply_nested_line_styles(input: NestedLineStyleInput<'_, '_, '_>) { + let NestedLineStyleInput { + db, + doc, + story, + range: prange, + props: pp, + para_chars: base_chars, + drop_end, + base_leading, + base_cap, + drop_grid, + drop_align, + drop_tabs, + cursor: cur, + frames, + columns: cols, + exceptions: hyph_exceptions, + suppressed_hyphens, + subst: sub, + style_table: table, + env, + shaped: sp, + } = input; + if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() { + // Nested line styles: find where the first lines end in this column, restyle them, and + // look again (the style changes the widths) until the lines settle. + let cols_here = &cols[cur.fi]; + let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); + let col = SplitCfg::of(pp).map_or(col, |c| c.sub_rect(col, 0)); + let spacing = spacing_for(pp, base_chars.size, story); + let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); + for _ in 0..3 { + // The same drop cap, spacing, hyphenation and breaker as the layout below. + let mut gl = sp.glyphs.clone(); + let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); + let width = |j: usize| { + let ind = match &dc { + Some(dc) if j < dc.lines => dc.indent(), + _ if j == 0 => pp.first_line_indent, + _ => 0.0, + }; + (col.width() - pp.left_indent - pp.right_indent - ind).max(1.0) + }; + apply_desired_spacing(&mut gl, pp); + mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); + let mut hy = hyphenation_points(&story.text, &gl, pp, hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), base_chars)); + suppress_hyphens(&mut hy, &gl, suppressed_hyphens); + let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { + let tab = |j: usize, x: f64, i: usize| { + let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; + tab_advance(&pp.tabs, pp.left_indent, ind + x, gl.get(i + 1..).unwrap_or_default()).0 + }; + breaker::greedy(&gl, &hy, &spacing, &width, &tab) + } else if pp.balance_ragged && !spacing.justify { + breaker::balanced(&gl, &hy, &spacing, &width) + } else { + breaker::knuth_plass(&gl, &hy, &spacing, &width) + }; + // Byte where line `k` starts (the paragraph end past the last line). + let byte_at = + |k: usize| if k == 0 { prange.start } else { breaks.get(k - 1).and_then(|b| gl.get(b.next)).map_or(prange.end, |g| g.byte) }; + let mut want = Vec::new(); + let mut line = 0usize; + for nl in &pp.nested_line_styles { + if line >= breaks.len() || nl.lines == 0 { + break; + } + let end = line + nl.lines as usize; + if !nl.style.is_empty() && nl.style != designcraft_doc::NO_CHAR_STYLE { + want.push((byte_at(line)..byte_at(end).max(byte_at(line)), nl.style.clone())); + } + line = end; + } + if want == lines { + break; + } + lines = want; + *sp = shape::shape_para( + shape::ShapeInput { + db, + styles: &doc.styles, + story, + range: prange.clone(), + para_chars: base_chars, + auto_leading: env, + sub, + nested: &pp.nested_styles, + grep: &pp.grep_styles, + lines: &lines, + drop_cap: (drop_end, &pp.drop_cap_style), + }, + &mut *table, + ); + } + } +} + +struct PlacementShared<'a, 'font> { + doc: &'a Document, + story: &'a Story, + frames: &'a [FrameSpec], + cols: &'a [Vec], + opts: &'a ComposeOptions, + db: &'a ScopedFonts<'font>, + out: &'a mut ComposedStory, + styles_tab: &'a mut Vec, + cur: &'a mut Cursor, + notes: &'a mut Notes, + snaps: &'a [Snapshot], + note_snaps: &'a [(u32, Option, usize)], + list_counter: &'a mut ListCounter, + force_col: &'a mut [bool], + line_cap: &'a mut [Option], + no_hyph: &'a mut [Vec], + trial: &'a mut Option, + limits: &'a mut Rc>, + balance_runs: &'a mut usize, + balance_budget: usize, + split_trial: &'a mut Option>, + split_limits: &'a mut Rc>, + split_runs: &'a mut usize, + split_budget: usize, + hyph_relays: &'a mut usize, + restores: &'a mut usize, +} + +struct ParagraphLines<'a> { + paragraph: usize, + range: Range, + props: &'a designcraft_doc::ParaProps, + glyphs: &'a [Glyph], + hyph_after: &'a [bool], + drop_cap: Option<&'a DropCap>, + base_chars: &'a designcraft_doc::CharProps, + mark_chars: &'a designcraft_doc::CharProps, + mark_leading: f64, + base_leading: f64, + last_line_mark: Option, + base_size: f64, + spacing: &'a Spacing, + bidi_info: &'a unicode_bidi::BidiInfo<'a>, + sub_objects: &'a HashMap, + spanning: bool, + at_top: bool, + paragraph_count: usize, + info: &'a [ParaInfo], + label_aligned: usize, + label_tab: bool, + list_align: ListAlign, +} + +enum LinePlacement { + Complete { line_no: usize, col_first_line: usize, first_line_rect: Option<(usize, f64, f64, f64)> }, + Restart(usize), + Overset, + StoryComplete, +} + +struct PositionLineInput<'a, 'font> { + frame: &'a FrameSpec, + column: Rect, + glyphs: &'a [Glyph], + hyph_after: &'a [bool], + range_start: usize, + props: &'a designcraft_doc::ParaProps, + cursor: &'a mut Cursor, + line_no: usize, + carry: &'a mut Vec<(f64, f64)>, + carry_baseline: &'a mut f64, + carry_grid: &'a mut Option, + para_block: &'a mut Option<((usize, usize), f64)>, + index: usize, + glyph_offset: usize, + breaks: &'a [Break], + break_index: usize, + line_glyphs: &'a [Glyph], + spacing: &'a Spacing, + ascent: f64, + tops: Tops, + descent: f64, + lead: f64, + reference: f64, + model: designcraft_doc::cjk::LeadingModel, + advance: f64, + base_size: f64, + rtl: bool, + db: &'a ScopedFonts<'font>, + mark_leading: f64, + mark_chars: &'a designcraft_doc::CharProps, + last_line_mark: Option, +} + +struct LinePosition { + x0: f64, + x1: f64, + baseline: f64, + grid_end: Option, + continues: bool, +} + +fn position_line(input: PositionLineInput<'_, '_>) -> LinePosition { + let PositionLineInput { + frame: f, + column: col, + glyphs, + hyph_after, + range_start, + props: pp, + cursor: cur, + line_no, + carry, + carry_baseline, + carry_grid, + para_block, + index: s, + glyph_offset: g0, + breaks, + break_index: k, + line_glyphs, + spacing, + ascent: asc, + tops, + descent: desc, + lead, + reference, + model, + advance, + base_size, + rtl, + db, + mark_leading, + mark_chars, + last_line_mark, + } = input; + let (mut x0, mut x1) = (col.x0, col.x1); + let ind = pp.left_indent + pp.right_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }; + let need = if f.exclusions.is_empty() && f.outline.is_none() { + 0.0 + } else { + let w = breaker::first_word_width(glyphs.get(s..).unwrap_or_default(), hyph_after.get(s..).unwrap_or_default(), spacing); + if w > col.width() - ind { 0.0 } else { w } + }; + let holds = |&(a, b): &(f64, f64)| b - a - ind + 0.01 >= need; + let mut continues = false; + let mut baseline = *carry_baseline; + let mut grid_end = None; + if let Some(i) = carry.iter().position(holds) { + continues = i == 0; + grid_end = *carry_grid; + (x0, x1) = carry.get(i).copied().unwrap_or((x0, x1)); + carry.drain(..=i); + } else { + carry.clear(); + baseline = cur.next_baseline(f, col, advance, asc, tops, pp); + if cur.last_baseline.is_some() { + baseline += cur.last_reference - reference; + } + let grid = f.grid.filter(|&(_, inc)| { + inc > 0.0 && inc.is_finite() && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) + }); + if let Some((g_start, inc)) = grid { + let n = pp.grid_gyoudori.min(grid::MAX_GYOUDORI); + match para_block.filter(|(at, _)| line_no > 0 && *at == (cur.fi, cur.col)) { + Some((_, end)) => grid_end = Some(end), + None => { + let r = grid::reference_offset(line_glyphs, pp.grid_reference); + let block = n > 0 && pp.paragraph_gyoudori && line_no == 0; + let span = if block { + let aki_below = |line_model, line_lead| (line_model == designcraft_doc::cjk::LeadingModel::AkiBelow).then_some(line_lead); + let (mut down, mut prev, mut prev_aki, mut last_r) = (0.0, reference, aki_below(model, lead), r); + for (j, next_break) in breaks.iter().enumerate().skip(k + 1) { + let (ns, ne) = (g0 + next_break.start, g0 + next_break.end); + let next_glyphs = glyphs.get(ns..ne.max(ns)).unwrap_or_default(); + let (_, _, _, next_lead) = line_metrics(next_glyphs, glyphs, ns, mark_leading, mark_chars.size, db, mark_chars); + let next_lead = match last_line_mark { + Some(mark) if j + 1 == breaks.len() => next_lead.max(mark), + _ => next_lead, + }; + let (next_reference, next_model) = cjk_line_reference(next_glyphs); + down += prev_aki.unwrap_or(next_lead) + prev - next_reference; + prev = next_reference; + prev_aki = aki_below(next_model, next_lead); + last_r = grid::reference_offset(next_glyphs, pp.grid_reference); + } + (down + last_r - r).max(0.0) + } else { + 0.0 + }; + let lines = if block { grid::lines_for(span, inc, n) } else { n.max(1) }; + let free = cur.grid_next.filter(|(b, _)| cur.last_baseline == Some(*b)).map(|(_, y)| y); + let band = grid::Band { natural: baseline + r, free, pending: cur.pending, fixed: n > 0, lines, span }; + let (y, end) = grid::place((g_start, inc), pp.grid_reference, band); + baseline = y - r; + grid_end = Some(end); + if block { + *para_block = Some(((cur.fi, cur.col), end)); + } + } + } + } + let on_grid = baseline; + let snap = |y: f64| grid.map_or(y, |(_, inc)| on_grid + ((y - on_grid) / inc - 1e-6).ceil().max(0.0) * inc); + if !f.exclusions.is_empty() || f.outline.is_some() { + let jump = cur.jump(f, advance, asc, tops); + let line_start = glyphs.get(s).map_or(range_start, |g| g.byte); + let mut tries = 0; + loop { + let Some((next_baseline, segments)) = wrap_slot(f, col, baseline, asc, desc, jump, base_size, rtl, line_start, &holds) else { + baseline = baseline.max(col.y1) + 1.0; + carry.clear(); + break; + }; + let pushed = next_baseline > baseline; + baseline = next_baseline; + if let Some((&first, rest)) = segments.split_first() { + (x0, x1) = first; + *carry = rest.to_vec(); + } + tries += 1; + if !pushed || grid.is_none() || tries > 4000 { + break; + } + baseline = snap(baseline); + } + } + grid_end = grid_end.map(|end| end + baseline - on_grid); + *carry_baseline = baseline; + *carry_grid = grid_end; + } + LinePosition { x0, x1, baseline, grid_end, continues } +} + +struct BreakInput<'a> { + glyphs: &'a [Glyph], + hyphens: &'a [bool], + props: &'a designcraft_doc::ParaProps, + slots: &'a [(f64, f64)], + column: Rect, + drop_cap: Option<&'a DropCap>, + line_no: usize, + col_first_line: usize, + rtl: bool, + has_tabs: bool, + spacing: &'a Spacing, +} + +fn choose_paragraph_breaks(input: BreakInput<'_>) -> Vec { + let BreakInput { glyphs, hyphens, props: pp, slots, column: col, drop_cap, line_no, col_first_line, rtl, has_tabs, spacing } = input; + let width = |j: usize| -> f64 { + let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); + let indent = pp.left_indent + + pp.right_indent + + match drop_cap { + Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.indent(), + _ if line_no + j == 0 => pp.first_line_indent, + _ => 0.0, + }; + (x1 - x0 - indent).max(1.0) + }; + if pp.composer.single_line() || has_tabs || glyphs.len() > 4000 { + let tab = |j: usize, x: f64, i: usize| { + let x0 = slots.get(j).map_or(col.x0, |s| s.0); + let indent = pp.left_indent + + match drop_cap { + Some(dc) if line_no + j < dc.lines && col_first_line == 0 => { + if rtl { + 0.0 + } else { + dc.indent() + } + } + _ if line_no + j == 0 => pp.first_line_indent, + _ => 0.0, + }; + tab_advance(&pp.tabs, pp.left_indent, x0 + indent + x - col.x0, glyphs.get(i + 1..).unwrap_or_default()).0 + }; + breaker::greedy(glyphs, hyphens, spacing, &width, &tab) + } else if pp.balance_ragged && !spacing.justify { + breaker::balanced(glyphs, hyphens, spacing, &width) + } else { + breaker::knuth_plass(glyphs, hyphens, spacing, &width) + } +} + +struct LineFitInput<'a> { + doc: &'a Document, + story: &'a Story, + cols: &'a [Vec], + opts: &'a ComposeOptions, + out: &'a mut ComposedStory, + cur: &'a mut Cursor, + notes: &'a mut Notes, + snaps: &'a [Snapshot], + note_snaps: &'a [(u32, Option, usize)], + list_counter: &'a mut ListCounter, + force_col: &'a mut [bool], + line_cap: &'a mut [Option], + no_hyph: &'a mut [Vec], + split_trial: &'a mut Option>, + split_limits: &'a mut Rc>, + split_runs: &'a mut usize, + split_budget: usize, + balance_runs: usize, + hyph_relays: &'a mut usize, + restores: &'a mut usize, + paragraph_count: usize, + info: &'a [ParaInfo], + props: &'a designcraft_doc::ParaProps, + paragraph: usize, + line_no: usize, + col_first_line: usize, + at_top: bool, + remaining_lines: usize, + frame: &'a FrameSpec, + column: Rect, + line_glyphs: &'a [Glyph], + baseline: f64, + descent: f64, + hyphen_at: Option, + sub_objects: &'a HashMap, + carry: &'a mut Vec<(f64, f64)>, +} + +enum LineFit { + Ready(Vec), + Restart(usize), + NextSub, + NextColumn, +} + +fn fit_line(input: LineFitInput<'_>) -> LineFit { + let LineFitInput { + doc, + story, + cols, + opts, + out, + cur, + notes, + snaps, + note_snaps, + list_counter, + force_col, + line_cap, + no_hyph, + split_trial, + split_limits, + split_runs, + split_budget, + balance_runs, + restores, + paragraph_count, + info, + props: pp, + paragraph: pi, + line_no, + col_first_line, + at_top, + remaining_lines, + frame: f, + column: col, + line_glyphs, + baseline, + descent: desc, + hyph_relays, + hyphen_at, + sub_objects, + carry, + } = input; + let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line; + let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() }; + let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); + let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f }; + let reserve = notes.reserve(doc, note_column, &line_notes, opts); + let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, note_column, &[], opts) } else { reserve }; + let fit_descent = + if f.opts.path.is_some() || f.vertical || reserve > 0.0 || line_glyphs.iter().any(|g| sub_objects.get(&g.byte).is_some_and(|o| !o.custom)) { + desc + } else { + 0.0 + }; + let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped; + if !fits + && !pp.hyph_across_column + && line_no > col_first_line + && let Some(at) = hyphen_at + && *hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1)) + { + *hyph_relays += 1; + no_hyph[pi].push(at); + rewind(pi, snaps, note_snaps, notes, out, cur, list_counter, force_col, line_cap, no_hyph); + return LineFit::Restart(pi); + } + if fits { + return LineFit::Ready(line_notes); + } + if cur.can_next_sub() { + cur.next_sub(); + carry.clear(); + return LineFit::NextSub; + } + if let Some(key) = cur.split_key() + && limit_of(split_limits, key).is_some_and(f64::is_finite) + { + let j = if split_trial.as_ref().is_some_and(|t| t.key == key) { + trial_failed(split_trial, split_limits, *split_runs < split_budget) + } else { + set_limit(split_limits, key, Some(f64::INFINITY)); + Some(key.2) + }; + if let Some(j) = j { + *split_runs += 1; + rewind(j, snaps, note_snaps, notes, out, cur, list_counter, force_col, line_cap, no_hyph); + return LineFit::Restart(j); + } + } + if !capped && cur.split.is_none_or(|s| s.part_lines == 0) { + let ctx = KeepCtx { + pi, + line_no, + in_col: line_no - col_first_line, + started_here: col_first_line == 0, + at_top, + remaining: remaining_lines, + here: (cur.fi, cur.col), + }; + if *restores < (4 * paragraph_count + 64) * (balance_runs + 1) + && let Some(action) = keep_violation(&ctx, pp, info, force_col, line_cap) + { + let j = match action { + KeepAction::Force(j) => { + force_col[j] = true; + j + } + KeepAction::Cap(j, count) => { + line_cap[j] = Some(count); + j + } + }; + *restores += 1; + rewind(j, snaps, note_snaps, notes, out, cur, list_counter, force_col, line_cap, no_hyph); + return LineFit::Restart(j); + } + } + carry.clear(); + LineFit::NextColumn +} + +struct PlacementOverflow<'a> { + story: &'a Story, + range: &'a Range, + glyphs: &'a [Glyph], + glyph_index: usize, + trial: &'a mut Option, + limits: &'a mut Rc>, + balance_runs: &'a mut usize, + balance_budget: usize, + snaps: &'a [Snapshot], + note_snaps: &'a [(u32, Option, usize)], + notes: &'a mut Notes, + out: &'a mut ComposedStory, + cur: &'a mut Cursor, + list_counter: &'a mut ListCounter, + force_col: &'a mut [bool], + line_cap: &'a mut [Option], + no_hyph: &'a mut [Vec], +} + +fn placement_overflow(input: PlacementOverflow<'_>) -> LinePlacement { + let PlacementOverflow { + story, + range, + glyphs, + glyph_index, + trial, + limits, + balance_runs, + balance_budget, + snaps, + note_snaps, + notes, + out, + cur, + list_counter, + force_col, + line_cap, + no_hyph, + } = input; + if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) { + *balance_runs += 1; + rewind(j, snaps, note_snaps, notes, out, cur, list_counter, force_col, line_cap, no_hyph); + return LinePlacement::Restart(j); + } + if range.is_empty() && range.start >= story.text.len() { + return LinePlacement::StoryComplete; + } + out.overset_at = Some(glyphs.get(glyph_index).map(|g| g.byte).unwrap_or(range.start)); + LinePlacement::Overset +} + +fn advance_placement_column(cursor: &mut Cursor, spanning: bool, columns: &[Vec]) { + if spanning { + cursor.next_frame(); + } else if cursor.can_next_sub() { + cursor.next_sub(); + } else { + cursor.next_column(columns); + } +} + +fn start_placement(cursor: &mut Cursor, start: StartParagraph, spanning: bool, frames: &[FrameSpec], columns: &[Vec], doc: &Document) { + if matches!(start, StartParagraph::NextColumn) { + let (fi, col) = (cursor.fi, cursor.col); + advance_placement_column(cursor, spanning, columns); + if cursor.fi == fi && cursor.col != col { + cursor.band.tail = Some((cursor.pi, cursor.col)); + } + } else { + cursor.start(start, columns, frames, doc); + } +} + +fn place_sub_objects( + story: &Story, + frame: &mut FrameText, + objects: &HashMap, + line_index: usize, + line_glyphs: &[Glyph], + base_size: f64, +) { + if objects.is_empty() { + return; + } + let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); + let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_size * 0.75 }; + for glyph in frame.lines[line_index].glyphs.iter().filter(|g| g.len > 0) { + if let Some(object) = objects.get(&glyph.byte) + && let Some(source) = story.objects.get(object.index) + { + frame.objects.push(PlacedObject { + index: object.index, + object: Arc::clone(source), + origin: Point::ZERO, + size: (object.w, object.h), + line: line_index, + x: glyph.x, + text_ascent, + }); + } + } +} + +fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLines<'_>) -> LinePlacement { + let PlacementShared { + doc, + story, + frames, + cols, + opts, + db, + out, + styles_tab, + cur, + notes, + snaps, + note_snaps, + list_counter, + force_col, + line_cap, + no_hyph, + trial, + limits, + balance_runs, + balance_budget, + split_trial, + split_limits, + split_runs, + split_budget, + hyph_relays, + restores, + } = shared; + let ParagraphLines { + paragraph: pi, + range: prange, + props: pp, + glyphs, + hyph_after, + drop_cap, + base_chars, + mark_chars, + mark_leading, + base_leading, + last_line_mark, + base_size, + spacing, + bidi_info, + sub_objects, + spanning, + at_top, + paragraph_count: np, + info, + label_aligned, + label_tab, + list_align, + } = paragraph; + let mut g0 = 0usize; + let mut line_no = 0usize; + // Paragraph line number of the first line in the current column. + let mut col_first_line = 0usize; + // The story byte at which the last line set ended at an automatic hyphenation point. + let mut hyphen_at: Option = None; + let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); + let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span + let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; + // Paragraph gyoudori: the (frame, column) of the block and the grid line after it. + let mut para_block: Option<((usize, usize), f64)> = None; + // Segments of a line already placed (beside an object with text on both sides) that the + // next lines of the paragraph fill before a new line starts, and that line's baseline. + let mut carry: Vec<(f64, f64)> = Vec::new(); + let mut carry_baseline = 0.0; + // The grid line after the carried line's band (see `grid_end`). + let mut carry_grid: Option = None; + // Re-breaks after a line's segment turned out other than the breaker assumed. + let mut rebreaks = 0usize; + loop { + if cur.fi >= frames.len() { + // An empty last paragraph isn't overset text, as in InDesign. + return placement_overflow(PlacementOverflow { + story, + range: &prange, + glyphs, + glyph_index: g0, + trial, + limits, + balance_runs, + balance_budget, + snaps, + note_snaps, + notes, + out, + cur, + list_counter, + force_col, + line_cap, + no_hyph, + }); + } + let f = &frames[cur.fi]; + let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col = cur.clip(base); + // A spanning paragraph isn't held to the balanced bottom of the columns above it. + let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; + let col = cur.split_rect(col); + // Estimate slots for the breaker with the paragraph's base leading. + // A shaped frame's slots change from line to line, so they are estimated with the + // band the lines are placed with (or a taller one, so no line outgrows its slot). + let (est_asc, est_band) = if f.outline.is_some() { + let (a, _, d, _) = line_metrics(&[], glyphs, g0, mark_leading, mark_chars.size, db, mark_chars); + (a, (a.max(base_chars.size * 0.8), d.max(base_chars.size * 0.25))) + } else { + (base_chars.size * 0.75, (base_chars.size * 0.8, 0.0)) + }; + let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 }; + let est_first = if carry.is_empty() { cur.next_baseline(f, col, base_leading, est_asc, est_tops, pp) } else { carry_baseline + base_leading }; + let est_jump = cur.jump(f, base_leading, est_asc, est_tops); + let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, est_band, &glyphs[g0..], rtl, &carry); + let breaks = choose_paragraph_breaks(BreakInput { + glyphs: &glyphs[g0..], + hyphens: &hyph_after[g0..], + props: pp, + slots: &slots, + column: col, + drop_cap, + line_no, + col_first_line, + rtl, + has_tabs, + spacing, + }); + let mut moved = false; + for (k, b) in breaks.iter().enumerate() { + let (s, e) = (g0 + b.start, g0 + b.end); + let line_glyphs = &glyphs[s..e.max(s)]; + let (asc, tops, desc, lead) = line_metrics(line_glyphs, glyphs, s, mark_leading, mark_chars.size, db, mark_chars); + let lead = match last_line_mark { + Some(m) if k + 1 == breaks.len() => lead.max(m), + _ => lead, + }; + let (reference, model) = cjk_line_reference(line_glyphs); + let advance = if cur.last_baseline.is_some() { cur.aki_below.unwrap_or(lead) } else { lead }; + let position = position_line(PositionLineInput { + frame: f, + column: col, + glyphs, + hyph_after, + range_start: prange.start, + props: pp, + cursor: cur, + line_no, + carry: &mut carry, + carry_baseline: &mut carry_baseline, + carry_grid: &mut carry_grid, + para_block: &mut para_block, + index: s, + glyph_offset: g0, + breaks: &breaks, + break_index: k, + line_glyphs, + spacing, + ascent: asc, + tops, + descent: desc, + lead, + reference, + model, + advance, + base_size, + rtl, + db, + mark_leading, + mark_chars, + last_line_mark, + }); + let LinePosition { x0, x1, baseline, grid_end, continues } = position; + // The breaker set this line for another segment width: re-break the rest of the + // paragraph from here, starting with this line's segments as they are. A pass + // re-breaks at its first line only for a new line, whose segments it carries, so + // every re-break makes progress; their number is capped too. + // Past the estimated slots the breaker assumes the whole column. Check that + // fallback too: narrow wrap segments may need more lines than the estimate. + let (estimated_x0, estimated_x1) = slots.get(k).copied().unwrap_or((col.x0, col.x1)); + if (k > 0 || !continues) && rebreaks < 256 && ((estimated_x1 - estimated_x0) - (x1 - x0)).abs() > 0.5 { + rebreaks += 1; + carry.insert(0, (x0, x1)); + g0 = s; + moved = true; + break; + } + let line_notes = match fit_line(LineFitInput { + doc, + story, + cols, + opts, + out, + cur, + notes, + snaps, + note_snaps, + list_counter, + force_col, + line_cap, + no_hyph, + split_trial, + split_limits, + split_runs, + split_budget, + balance_runs: *balance_runs, + hyph_relays, + restores, + paragraph_count: np, + info, + props: pp, + paragraph: pi, + line_no, + col_first_line, + at_top, + remaining_lines: breaks.len() - k, + frame: f, + column: col, + line_glyphs, + baseline, + descent: desc, + hyphen_at, + sub_objects, + carry: &mut carry, + }) { + LineFit::Ready(line_notes) => line_notes, + LineFit::Restart(j) => return LinePlacement::Restart(j), + LineFit::NextSub => { + g0 = s; + col_first_line = line_no; + moved = true; + break; + } + LineFit::NextColumn => { + g0 = s; + advance_placement_column(cur, spanning, cols); + col_first_line = line_no; + moved = true; + break; + } + }; + let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); + let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f }; + // A drop cap sits at the start-side indent; the first-line indent doesn't move it. + let beside = drop_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); + let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 }; + let (ind_l, ind_r) = match beside { + Some(w) if rtl => (pp.left_indent, pp.right_indent + w), + Some(w) => (pp.left_indent + w, pp.right_indent), + // The first-line indent is at the paragraph's start side, which is the right + // one in a right-to-left paragraph: applied on the left it moved the line the + // wrong way, out of the frame for the hanging indent a numbered list uses + // (#1448). + None if rtl => (pp.left_indent, pp.right_indent + first_line), + None => (pp.left_indent + first_line, pp.right_indent), + }; + let lx0 = x0 + ind_l; + let lx1 = x1 - ind_r; + let last = k + 1 == breaks.len(); + // The break character that ends this line, if any (it is the line's last glyph). + let brk = + if b.forced && e > s { e.checked_sub(1).and_then(|i| glyphs.get(i)).map(|g| g.ch).filter(|&c| breaker::is_forced(c)) } else { None }; + // A column, frame or page break ends the line as a paragraph's last line (last-line + // alignment); a justified line ended by a forced line break stays justified. + let ends_para = last || brk.is_some_and(story::is_break_char); + let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; + let (mut placed, end_x, ratio) = + layout_line(glyphs, s, e, b.hyphen, lx0, lx1, col.x0, pp, spacing, ends_para, forced_mid, f.left_page, bidi_info); + let end_x = if line_no == 0 && s == 0 { align_label(&mut placed, label_aligned, label_tab, list_align, end_x) } else { end_x }; + // Warichu runs before ruby so a reading is placed over the stacked note, and before + // the trimmed spaces and the drop cap join the line, so neither is stacked. + let end_x = end_x + warichu::place(styles_tab, &mut placed); + // The spaces the break trimmed off the end follow the line undrawn, so the caret + // and the selection reach them; alignment and justification ignore them. + if pp.direction != designcraft_doc::TextDirection::RightToLeft && !placed.iter().any(|g| g.rtl) { + let mut x = end_x; + for g in glyphs.get(e..(g0 + b.next).min(glyphs.len())).unwrap_or(&[]).iter().filter(|g| g.is_space()) { + placed.push(PlacedGlyph { visible: false, ..place(g, x) }); + x += g.adv; + } + } + let mut drop_box = None; + if line_no == 0 + && let Some(dc) = &drop_cap + { + let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent - dc.lsb }; + placed.splice(0..0, place_drop_cap(dc, at, rtl)); + drop_box = Some(DropCapBox { + end: dc.end, + baseline: baseline + dc.drop, + // From its line's top, or its own top when it rises above that line. + rect: Rect::new(at, (baseline - asc).min(baseline + dc.drop - dc.cap), at + dc.width, baseline + dc.drop + desc), + }); + } + ruby::annotate(db, styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical); + kenten::annotate(db, styles_tab, &mut placed, doc.settings.glyph_fallback); + let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; + let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); + let range_start = if line_no == 0 { prange.start } else { range_start }; + let ft = &mut out.frames[cur.fi]; + if line_no == 0 { + first_line_rect = Some((cur.fi, baseline, asc, x0)); + } + if let Some(sb) = cur.split.as_mut() { + sb.line0.get_or_insert(ft.lines.len()); + sb.sub_lines += 1; + sb.part_lines += 1; + } + ft.lines.push(Line { + column: cur.col as u32, + baseline, + x0, + x1, + ascent: asc, + descent: desc, + leading: lead, + range: range_start..range_end, + para: pi, + glyphs: placed, + hyphenated: b.hyphen, + first_in_para: line_no == 0, + last_in_para: last, + end_x, + spacing: ratio, + hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max), + keep_violation: false, + drop_cap: drop_box, + }); + let placed_notes = !line_notes.is_empty(); + for k in line_notes { + notes.place(doc, k, note_column, opts); + } + // Notes spanning the columns take room from the columns before this one too: re-lay + // the frame with that room left. + if placed_notes { + let r = notes.reserve(doc, note_column, &[], opts); + if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) + && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) + { + rewind(j, snaps, note_snaps, notes, out, cur, list_counter, force_col, line_cap, no_hyph); + return LinePlacement::Restart(j); + } + } + let ft = &mut out.frames[cur.fi]; + place_sub_objects(story, ft, sub_objects, ft.lines.len() - 1, line_glyphs, base_chars.size); + cur.last_baseline = Some(baseline); + cur.last_reference = reference; + cur.aki_below = (model == designcraft_doc::cjk::LeadingModel::AkiBelow).then_some(lead); + cur.last_descent = desc; + cur.pending = 0.0; + cur.grid_next = grid_end.map(|end| (baseline, end)); + hyphen_at = + e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); + line_no += 1; + // A column / frame / page break inside the paragraph: the rest of it continues in the + // new column/frame/page, re-broken there. (A break that ends the paragraph moves the + // next paragraph instead.) + if let Some(start) = forced_break_start(b.forced, k + 1 < breaks.len(), brk) { + start_placement(cur, start, spanning, frames, cols, doc); + col_first_line = line_no; + g0 += b.next; + carry.clear(); + moved = true; + break; + } + // An own-story anchored object anchored in this line wraps the lines after it, + // which were broken without it: break them again. + if k + 1 < breaks.len() && line_has_wrapping_exclusion(f, range_start..range_end, col, baseline) { + g0 += b.next; + moved = true; + break; + } + } + if !moved { + break; + } + } + LinePlacement::Complete { line_no, col_first_line, first_line_rect } +} + +struct ParagraphShapeInput<'a, 'font> { + db: &'a ScopedFonts<'font>, + doc: &'a Document, + story: &'a Story, + opts: &'a ComposeOptions, + range: Range, + props: &'a mut designcraft_doc::ParaProps, + para_chars: &'a designcraft_doc::CharProps, + frame: Option<&'a FrameSpec>, + paragraph: usize, + subst: &'a SubstCtx, + exceptions: &'a HashMap>, + suppressed_hyphens: &'a [usize], + named_labels: &'a [Option], + list_counter: &'a mut ListCounter, + styles: &'a mut Vec, + missing_fonts: &'a mut HashMap, + frames: &'a [FrameSpec], + columns: &'a [Vec], + cursor: &'a Cursor, +} + +struct ShapedParagraph { + sub_objects: HashMap, + base_leading: f64, + above: (String, f32), + below: (String, f32), + drop_cap: Option, + label_aligned: usize, + label_tab: bool, + list_align: ListAlign, + glyphs: Vec, + bidi_text: String, + hyph_after: Vec, + base_size: f64, + spacing: Spacing, + mark_chars: designcraft_doc::CharProps, + mark_leading: f64, + last_line_mark: Option, + para_box: Option, +} + +fn shape_paragraph(input: ParagraphShapeInput<'_, '_>) -> ShapedParagraph { + let ParagraphShapeInput { + db, + doc, + story, + opts, + range: prange, + props: pp, + para_chars: base_chars, + frame: cur_frame, + paragraph, + subst: sub, + exceptions: hyph_exceptions, + suppressed_hyphens, + named_labels, + list_counter, + styles, + missing_fonts, + frames, + columns, + cursor, + } = input; + let drop_end = drop_cap_end(&story.text, prange.clone(), pp, sub.vertical); + let mut table = StyleTable { styles, missing: missing_fonts }; + let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback }; + let mut sp = shape::shape_para( + shape::ShapeInput { + db, + styles: &doc.styles, + story, + range: prange.clone(), + para_chars: base_chars, + auto_leading: env, + sub, + nested: &pp.nested_styles, + grep: &pp.grep_styles, + lines: &[], + drop_cap: (drop_end, &pp.drop_cap_style), + }, + &mut table, + ); + // An empty paragraph has no glyphs of its own, so its only addressable character is its + // hidden paragraph mark (the `\n` just past `prange.end`). A leading/size override applied + // there (e.g. selecting the pilcrow to adjust line height) still needs to set the line's + // height, so resolve the mark's own run instead of the paragraph's plain default when present. + let mark_chars = prange + .is_empty() + .then(|| story.runs().find(|(r, _)| r.contains(&prange.end))) + .flatten() + .map(|(_, fmt)| doc.styles.resolve_char(base_chars, fmt)) + .unwrap_or_else(|| base_chars.clone()); + let leading_chars = &mark_chars; + // Drop cap: the size and indent come from the text beside it (`base_*` when there's none). + let base_leading = match leading_chars.leading { + designcraft_doc::Leading::Auto => leading_chars.size * pp.auto_leading, + designcraft_doc::Leading::Points(v) => v, + }; + let base_cap = { + let face = db.face(&base_chars.font_family, &base_chars.font_style); + face.cap_height * base_chars.size / face.upem * base_chars.v_scale + }; + let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1); + // Align Left Edge (left-to-right paragraphs). + let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight; + // Tab stops for the drop cap, with the start-side indent (the right one, right to left). + let drop_tab_indent = if pp.direction == designcraft_doc::TextDirection::RightToLeft { pp.right_indent } else { pp.left_indent }; + apply_nested_line_styles(NestedLineStyleInput { + db, + doc, + story, + range: prange.clone(), + props: pp, + para_chars: base_chars, + drop_end, + base_leading, + base_cap, + drop_grid, + drop_align, + drop_tabs: (&pp.tabs[..], drop_tab_indent), + cursor, + frames, + columns, + exceptions: hyph_exceptions, + suppressed_hyphens, + subst: sub, + style_table: &mut table, + env, + shaped: &mut sp, + }); + // Rules in Text Color: the colour of the paragraph's first character (rule above) and last + // character (rule below). + let text_color = |g: Option<&Glyph>| { + g.and_then(|g| table.styles.get(g.style as usize)).map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint)) + }; + let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0))); + let drop_cap = split_drop_cap( + db, + &mut sp.glyphs, + drop_end, + pp.drop_cap_lines, + base_leading, + base_cap, + drop_grid, + drop_align, + (&pp.tabs[..], drop_tab_indent), + ); + // List labels take the default super/subscript settings. + let label_env = shape::TypeEnv { adv: Default::default(), ..env }; + let list_label = match pp.list_type { + // An empty paragraph has no bullet or number, and the numbering carries on past it. + designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None, + // A named list: carries on past other paragraphs (and from earlier stories). + designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { + Some(named_labels.get(paragraph).cloned().flatten().unwrap_or_else(|| pp.number_label(1))) + } + designcraft_doc::ListType::Numbers => { + list_counter.advance(pp); + Some(pp.number_label_in(list_counter)) + } + designcraft_doc::ListType::Bullets => Some(pp.bullet_label()), + designcraft_doc::ListType::None => { + *list_counter = ListCounter::default(); + None + } + }; + // The label's glyphs at the paragraph start, and how many of them (up to its first tab) + // the list alignment places. + let mut label_aligned = 0usize; + let mut label_tab = false; + if let Some(label) = &list_label { + let mut chars = label_chars(doc, pp, base_chars); + // The number is drawn in the Digits of the paragraph's first run, so Persian text + // gets Persian numbers whether Digits is set on the paragraph style or on the text + // (unless the number's character style sets its own). + if chars.digits == base_chars.digits + && let Some((_, fmt)) = story.runs().find(|(rr, _)| rr.end > prange.start && rr.start < prange.end) + { + chars.digits = doc.styles.resolve_char(base_chars, fmt).digits; + } + let n = prepend_label(db, &mut sp.glyphs, label, prange.start, &chars, label_env, &mut table); + for g in sp.glyphs.iter_mut().take(n) { + g.list_label = true; + } + label_aligned = sp.glyphs.get(..n).unwrap_or_default().iter().position(|g| g.ch == '\t').unwrap_or(n); + label_tab = label_aligned < n; + // Tab Position: a stop for the text after the label. + if let Some(t) = pp.list_tab.filter(|t| t.is_finite() && *t >= 0.0) + && !pp.tabs.iter().any(|s| (s.position - t).abs() < 1e-6) + { + pp.tabs.push(designcraft_doc::TabStop { position: t, align: TabAlign::Left, leader: String::new(), align_on: String::new() }); + pp.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + } + } + if paragraph == 0 + && let Some(label) = &opts.label + { + prepend_label(db, &mut sp.glyphs, label, prange.start, base_chars, label_env, &mut table); + label_aligned = 0; + } + let list_align = match pp.list_type { + _ if label_aligned == 0 || pp.direction == designcraft_doc::TextDirection::RightToLeft => designcraft_doc::ListAlign::Left, + designcraft_doc::ListType::Bullets => pp.bullet_align, + _ => pp.number_align, + }; + let para_box = ParaBox::of(doc, pp); + let mut glyphs = sp.glyphs; + if !pp.composer.japanese() { + // Every IDML's root style carries a leading model and character alignment, which only + // the Japanese composers use. + for g in &mut glyphs { + g.leading_model = designcraft_doc::cjk::LeadingModel::Roman; + g.character_alignment = designcraft_doc::cjk::CharacterAlignment::Baseline; + } + } + let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text); + bidi::resolve_mirroring( + &mut glyphs, + &unicode_bidi::BidiInfo::new( + &bidi_text, + Some(if pp.direction == designcraft_doc::TextDirection::RightToLeft { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }), + ), + ); + apply_desired_spacing(&mut glyphs, pp); + mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); + let mut hyph_after = hyphenation_points(&story.text, &glyphs, pp, hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), base_chars)); + suppress_hyphens(&mut hyph_after, &glyphs, suppressed_hyphens); + let base_size = base_chars.size; + let spacing = spacing_for(pp, base_size, story); + // An empty paragraph takes its height from its paragraph mark's character format. + let mark_chars = if glyphs.is_empty() { doc.styles.resolve_char(base_chars, story.char_format_of(prange.start)) } else { base_chars.clone() }; + let mark_leading = match mark_chars.leading { + designcraft_doc::Leading::Auto => mark_chars.size * pp.auto_leading, + designcraft_doc::Leading::Points(v) => v, + }; + // The paragraph mark is a character of the last line: its leading counts there like any + // other character's. It doesn't set the line's ascent or descent. A hidden or deleted + // mark takes no room. + let last_line_mark = story.text.get(prange.end..).filter(|t| t.starts_with('\n')).and_then(|_| { + let props = doc.styles.resolve_char(base_chars, story.format_after(prange.end)); + let hidden = props.change == designcraft_doc::ChangeMark::Deleted + || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))); + (!hidden).then_some(match props.leading { + designcraft_doc::Leading::Auto => props.size * pp.auto_leading, + designcraft_doc::Leading::Points(v) => v, + }) + }); + ShapedParagraph { + sub_objects: sub.objects.clone(), + base_leading, + above, + below, + drop_cap, + label_aligned, + label_tab, + list_align, + glyphs, + bidi_text, + hyph_after, + base_size, + spacing, + mark_chars, + mark_leading, + last_line_mark, + para_box, + } +} + +struct ParagraphFinishInput<'a> { + doc: &'a Document, + story: &'a Story, + frames: &'a [FrameSpec], + cols: &'a [Vec], + out: &'a mut ComposedStory, + cur: &'a mut Cursor, + notes: &'a mut Notes, + snaps: &'a [Snapshot], + note_snaps: &'a [(u32, Option, usize)], + list_counter: &'a mut ListCounter, + force_col: &'a mut [bool], + line_cap: &'a mut [Option], + no_hyph: &'a mut [Vec], + split_trial: &'a mut Option>, + split_limits: &'a mut Rc>, + split_runs: &'a mut usize, + split_budget: usize, + paragraph: usize, + paragraph_count: usize, + props: &'a designcraft_doc::ParaProps, + info: &'a mut Vec, + start_at: (usize, usize), + at_top: bool, + end_line: usize, + end_column_first_line: usize, + first_line_rect: Option<(usize, f64, f64, f64)>, + above: &'a (String, f32), + below: &'a (String, f32), + spanning: bool, +} + +enum ParagraphFinish { + Done(bool), + Restart(usize), +} + +fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish { + let ParagraphFinishInput { + doc, + story, + frames, + cols, + out, + cur, + notes, + snaps, + note_snaps, + list_counter, + force_col, + line_cap, + no_hyph, + split_trial, + split_limits, + split_runs, + split_budget, + paragraph: pi, + paragraph_count: np, + props: pp, + info, + start_at, + at_top, + end_line: line_no, + end_column_first_line: col_first_line, + first_line_rect, + above, + below, + spanning, + } = input; + let spans_in = |fi: usize| spanning && cols.get(fi).is_some_and(|c| c.len() > 1); + info.push(ParaInfo { + start: start_at, + at_top, + end: (cur.fi, cur.col), + lines: line_no, + lines_in_end_col: line_no - col_first_line, + keep_with_next: pp.keep_with_next, + keep_together: pp.keep_lines_together, + keep_all: pp.keep_all_lines, + keep_first: pp.keep_first as usize, + keep_last: pp.keep_last as usize, + }); + // The columns below a spanning paragraph start under it. + if spanning + && let Some(ft) = out.frames.get(cur.fi) + && let Some(l) = ft.lines.last().filter(|l| l.para == pi) + { + let top_band = Above { + top: l.baseline + l.descent, + baseline: l.baseline, + descent: l.descent, + reference: cur.last_reference, + aki_below: cur.aki_below, + pending: if spans_in(cur.fi) { pp.space_after.max(pp.span_space_after) } else { pp.space_after }, + }; + cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(top_band), tail: None }; + } + // Rules and shading for the paragraph. + if let Some((fi, bl, _, _)) = first_line_rect { + let ft = &mut out.frames[fi]; + if pp.rule_above.on { + let r = &pp.rule_above; + // The offset runs from the first line's baseline up to the rule's bottom edge. + let y = bl - r.offset; + let col = ft + .lines + .iter() + .find(|l| l.para == pi && l.first_in_para) + .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) + .unwrap_or((0.0, 0.0)); + let (color, tint) = rule_color(r, above); + ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color, tint, ..Deco::default() }); + } + } + if pp.rule_below.on + && let Some(ft) = out.frames.get_mut(cur.fi.min(frames.len().saturating_sub(1))) + && let Some(l) = ft.lines.iter().rev().find(|l| l.para == pi) + { + let r = &pp.rule_below; + let y = l.baseline + r.offset; + let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; + let (color, tint) = rule_color(r, below); + ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); + } + // The end of a split block: text after it continues below its deepest sub-column, which + // are balanced first. A block that ends the story fills its sub-columns in turn. + let mut ends_split = false; + if let Some(sb) = cur.split { + let next = story.paras.get(pi + 1).filter(|_| story.para_table(pi + 1).is_none()).and_then(|p| SplitCfg::of(&doc.styles.resolve_para(p).0)); + if next != Some(sb.cfg) { + cur.split = None; + ends_split = true; + let part = out.frames.get(cur.fi).and_then(|ft| { + let lines = ft.lines.get(sb.line0?..)?; + let top = lines.first().map(|l| l.baseline - l.ascent)?; + let deepest = lines.iter().map(|l| (l.baseline, l.descent)).max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)))?; + Some((sb.top.unwrap_or(top), deepest)) + }); + if pi + 1 < np + && let Some((top, (baseline, descent))) = part + { + let key = (cur.fi, cur.col, sb.para); + let active = split_trial.as_ref().is_some_and(|t| t.key == key); + if !active && let Some(t) = split_trial.take() { + set_limit(split_limits, t.key, None); + } + if sb.part_lines > 1 && (active || limit_of(split_limits, key).is_none() && *split_runs < split_budget) { + let bottom = baseline + descent; + let mut t = split_trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false }); + // This layout fits: its bottom is the best so far. + t.hi = t.hi.min(bottom); + let next = if t.done { t.hi } else { t.next() }; + set_limit(split_limits, key, Some(next)); + if !t.done { + *split_trial = Some(t); + *split_runs += 1; + rewind(key.2, snaps, note_snaps, notes, out, cur, list_counter, force_col, line_cap, no_hyph); + return ParagraphFinish::Restart(key.2); + } + } + cur.last_baseline = Some(baseline); + cur.last_descent = descent; + } + } + } + ParagraphFinish::Done(ends_split) +} + fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions, db: &FontDb) -> ComposedStory { let db = &db.scoped(doc.font_scope); let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() }; let mut styles_tab: Vec = Vec::new(); let mut missing_fonts: HashMap = HashMap::new(); - let cols: Vec> = frames - .iter() - .map(|f| { - let mut columns = f.columns(); - if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.vertical { - columns.reverse(); - } - columns - }) - .collect(); - out.frames = frames - .iter() - .zip(&cols) - .map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() }) - .collect(); + let (cols, frame_text) = frame_columns_and_text(frames, story.direction); + out.frames = frame_text; let mut cur = Cursor { fi: 0, col: 0, @@ -837,24 +2647,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let hyph_exceptions = doc.hyphenation_exception_map(); let mut list_counter = ListCounter::default(); // Named lists number independently of layout: one pass up front, for the labels. - let named_labels: Vec> = { - let mut counters: std::collections::HashMap = std::collections::HashMap::new(); - story - .paras - .iter() - .zip(¶_ranges) - .zip(&deleted) - .map(|((p, r), del)| { - let (pp, _) = doc.styles.resolve_para(p); - if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() || r.is_empty() || *del { - return None; - } - let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name)); - c.advance(&pp); - Some(pp.number_label_in(c)) - }) - .collect() - }; + let named_labels = named_list_labels(doc, story, ¶_ranges, &deleted); // Border and shading of each paragraph set, drawn once the lines are in place. let mut boxes: Vec> = vec![None; np]; // Keep options: paragraphs are re-laid from a snapshot when a keep is violated, either forced @@ -912,36 +2705,30 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi = j; continue 'paras; } - if pi >= np { - // The story ends here: Balance Columns evens out the columns of its last frame. - if balance_frames > 0 - && let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget) - { + match paragraph_progress(ParagraphProgressInput { + paragraph: pi, + count: np, + balance_frames, + trial: &mut trial, + limits: &mut limits, + cursor: &cur, + columns: &cols, + frames, + out: &out, + may_retry: balance_runs < balance_budget, + }) { + ParagraphProgress::Continue => {} + ParagraphProgress::Restart(j) => { balance_runs += 1; rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } - break 'paras; + ParagraphProgress::Complete => break 'paras, } if deleted.get(pi).copied().unwrap_or(false) { // A deleted paragraph takes no room; for keeps it stands in for the one before it. - let filler = info.last().map_or( - ParaInfo { - start: (cur.fi, cur.col), - at_top: cur.last_baseline.is_none(), - end: (cur.fi, cur.col), - lines: 0, - lines_in_end_col: 0, - keep_with_next: 0, - keep_together: false, - keep_all: false, - keep_first: 1, - keep_last: 1, - }, - |p| ParaInfo { lines_in_end_col: 0, ..p.clone() }, - ); - info.push(filler); + info.push(deleted_paragraph_info(&info, &cur)); pi += 1; continue; } @@ -950,333 +2737,110 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let (mut pp, base_chars) = doc.styles.resolve_para(pf); // A break character that ends the previous paragraph sends this one on, like a start option. // Deleted paragraphs in between don't count, nor does a deleted or hidden break. - let after_break = (0..pi).rev().find(|p| !deleted.get(*p).copied().unwrap_or(false)).and_then(|p| { - let r = para_ranges.get(p)?; - let (i, c) = story.text.get(r.clone())?.char_indices().next_back()?; - let start = break_start(c)?; - let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); - let props = doc.styles.resolve_char(&chars, story.char_format_at(r.start + i + c.len_utf8())); - let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); - (!gone).then_some(start) - }); + let after_break = previous_visible_break(doc, story, ¶_ranges, &deleted, pi); if let Some(t) = story.para_table(pi) { let start = after_break.unwrap_or(pp.start_paragraph); - if cur.last_baseline.is_some() { - cur.start(start, &cols, frames, doc); - } else { - cur.start_at_top(start, frames, doc); - } - let at_top = cur.last_baseline.is_none(); - let start_at = (cur.fi, cur.col); let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0); - if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { - if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { - balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); + match place_table_paragraph(TableParagraphInput { + doc, + table: t, + anchor, + paragraph: pi, + range_start: prange.start, + props: &pp, + start, + frames, + columns: &cols, + cursor: &mut cur, + out: &mut out, + opts, + trial: &mut trial, + limits: &mut limits, + balance_runs: &mut balance_runs, + balance_budget, + snaps: &snaps, + note_snaps: ¬e_snaps, + notes: &mut notes, + list_counter: &mut list_counter, + force_col: &mut force_col, + line_cap: &mut line_cap, + no_hyph: &mut no_hyph, + info: &mut info, + boxes: &mut boxes, + }) { + TableParagraphResult::Placed => { + pi += 1; + continue; + } + TableParagraphResult::Restart(j) => { pi = j; continue 'paras; } - out.overset_at = Some(prange.start); - break 'paras; + TableParagraphResult::Overset => break 'paras, } - info.push(ParaInfo { - start: start_at, - at_top, - end: (cur.fi, cur.col), - lines: 1, - lines_in_end_col: 1, - keep_with_next: 0, - keep_together: false, - keep_all: false, - keep_first: 1, - keep_last: 1, - }); - list_counter = ListCounter::default(); - boxes[pi] = None; - cur.pending += pp.space_after; - pi += 1; - continue; } // Bullets & numbering: generated prefix (shaped as its own glyphs, mapped to the paragraph start). let cur_frame = frames.get(cur.fi.min(frames.len().saturating_sub(1))); - let var_values = if var_story { - let page = opts.page.or_else(|| cur_frame.and_then(|f| f.page)); - var_cache.entry(page).or_insert_with(|| std::sync::Arc::new(vars::values(doc, page, opts.running.as_deref()))).clone() - } else { - Default::default() - }; - let mut sub = SubstCtx { - page_name: opts.page_name.clone().or_else(|| cur_frame.and_then(|f| f.page_name.clone())), - section_marker: opts.section_marker.clone().or_else(|| cur_frame.and_then(|f| f.section_marker.clone())), - vars: var_values, - hidden_conditions: doc.conditions.iter().filter(|c| !c.visible).map(|c| c.name.clone()).collect(), - vertical: cur_frame.is_some_and(|f| f.vertical), - auto_tcy: pp.auto_tcy.min(shape::AUTO_TCY_MAX), - auto_tcy_roman: pp.auto_tcy_include_roman, - rotate_roman: pp.rotate_roman, - ..Default::default() - }; - if !story.endnotes.is_empty() { - // Endnote reference numbers continue through the document. - let first = doc.endnote_start(story.id) + story.text[..prange.start].matches(designcraft_doc::ENDNOTE_REF).count() as u32; - for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::ENDNOTE_REF).enumerate() { - sub.notes.insert(prange.start + i, doc.endnote_options.label(first + k as u32)); - } - } - if !story.objects.is_empty() { - let before = story.text[..prange.start].matches(designcraft_doc::OBJECT_MARK).count(); - for (k, (i, _)) in story.text[prange.clone()].match_indices(designcraft_doc::OBJECT_MARK).enumerate() { - let Some(o) = story.objects.get(before + k) else { continue }; - let (w, h) = o.size(); - let (y_offset, space, custom) = match &o.position { - designcraft_doc::AnchorPosition::Inline { y_offset } => (Some(*y_offset), 0.0, false), - designcraft_doc::AnchorPosition::AboveLine { space_before, space_after, .. } => (None, space_before + space_after, false), - designcraft_doc::AnchorPosition::Custom { .. } => (Some(0.0), 0.0, true), - }; - sub.objects.insert(prange.start + i, shape::ObjectSpec { index: before + k, w, h, y_offset, space, custom }); - } - } - if !story.xrefs.is_empty() { - sub.xrefs = xref::texts_in(doc, story, prange.clone(), opts.xrefs.as_deref()); - } - if notes.active() { - notes.number_refs(doc, prange.clone(), cur_frame.and_then(|f| f.page), &mut sub); - } - let sub_objects = sub.objects.clone(); - let drop_end = drop_cap_end(&story.text, prange.clone(), &pp, sub.vertical); - let mut table = StyleTable { styles: &mut styles_tab, missing: &mut missing_fonts }; - let env = shape::TypeEnv { auto_leading: pp.auto_leading, adv: doc.settings.advanced_type, glyph_fallback: doc.settings.glyph_fallback }; - let mut sp = shape::shape_para( - db, - &doc.styles, + let sub = paragraph_substitution(SubstitutionInput { + doc, story, - pi, - prange.clone(), - &base_chars, - env, - &sub, - &mut table, - &pp.nested_styles, - &pp.grep_styles, - &[], - (drop_end, &pp.drop_cap_style), - ); - // An empty paragraph has no glyphs of its own, so its only addressable character is its - // hidden paragraph mark (the `\n` just past `prange.end`). A leading/size override applied - // there (e.g. selecting the pilcrow to adjust line height) still needs to set the line's - // height, so resolve the mark's own run instead of the paragraph's plain default when present. - let mark_chars = prange - .is_empty() - .then(|| story.runs().find(|(r, _)| r.contains(&prange.end))) - .flatten() - .map(|(_, fmt)| doc.styles.resolve_char(&base_chars, fmt)); - let leading_chars = mark_chars.as_ref().unwrap_or(&base_chars); - // Drop cap: the size and indent come from the text beside it (`base_*` when there's none). - let base_leading = match leading_chars.leading { - designcraft_doc::Leading::Auto => leading_chars.size * pp.auto_leading, - designcraft_doc::Leading::Points(v) => v, - }; - let base_cap = { - let face = db.face(&base_chars.font_family, &base_chars.font_style); - face.cap_height * base_chars.size / face.upem * base_chars.v_scale - }; - let drop_grid = cur_frame.and_then(|f| f.grid).filter(|_| pp.grid_align == GridAlign::AllLines).map(|g| g.1); - // Align Left Edge (left-to-right paragraphs). - let drop_align = pp.drop_cap_align_left && pp.direction == designcraft_doc::TextDirection::LeftToRight; - // Tab stops for the drop cap, with the start-side indent (the right one, right to left). - let drop_tabs = (&pp.tabs[..], if pp.direction == designcraft_doc::TextDirection::RightToLeft { pp.right_indent } else { pp.left_indent }); - if !pp.nested_line_styles.is_empty() && cur.fi < frames.len() { - // Nested line styles: find where the first lines end in this column, restyle them, and - // look again (the style changes the widths) until the lines settle. - let cols_here = &cols[cur.fi]; - let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); - let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0)); - let spacing = spacing_for(&pp, base_chars.size); - let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); - for _ in 0..3 { - // The same drop cap, spacing, hyphenation and breaker as the layout below. - let mut gl = sp.glyphs.clone(); - let dc = split_drop_cap(db, &mut gl, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); - let width = |j: usize| { - let ind = match &dc { - Some(dc) if j < dc.lines => dc.indent(), - _ if j == 0 => pp.first_line_indent, - _ => 0.0, - }; - (col.width() - pp.left_indent - pp.right_indent - ind).max(1.0) - }; - apply_desired_spacing(&mut gl, &pp); - mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); - let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); - suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice)); - let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { - let tab = |j: usize, x: f64, i: usize| { - let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; - tab_advance(&pp.tabs, pp.left_indent, ind + x, gl.get(i + 1..).unwrap_or_default()).0 - }; - breaker::greedy(&gl, &hy, &spacing, &width, &tab) - } else if pp.balance_ragged && !spacing.justify { - breaker::balanced(&gl, &hy, &spacing, &width) - } else { - breaker::knuth_plass(&gl, &hy, &spacing, &width) - }; - // Byte where line `k` starts (the paragraph end past the last line). - let byte_at = - |k: usize| if k == 0 { prange.start } else { breaks.get(k - 1).and_then(|b| gl.get(b.next)).map_or(prange.end, |g| g.byte) }; - let mut want = Vec::new(); - let mut line = 0usize; - for nl in &pp.nested_line_styles { - if line >= breaks.len() || nl.lines == 0 { - break; - } - let end = line + nl.lines as usize; - if !nl.style.is_empty() && nl.style != designcraft_doc::NO_CHAR_STYLE { - want.push((byte_at(line)..byte_at(end).max(byte_at(line)), nl.style.clone())); - } - line = end; - } - if want == lines { - break; - } - lines = want; - sp = shape::shape_para( - db, - &doc.styles, - story, - pi, - prange.clone(), - &base_chars, - env, - &sub, - &mut table, - &pp.nested_styles, - &pp.grep_styles, - &lines, - (drop_end, &pp.drop_cap_style), - ); - } - } - // Rules in Text Color: the colour of the paragraph's first character (rule above) and last - // character (rule below). - let text_color = |g: Option<&Glyph>| { - g.and_then(|g| table.styles.get(g.style as usize)) - .map_or((base_chars.fill.clone(), base_chars.fill_tint), |s| (s.fill.clone(), s.fill_tint)) - }; - let (above, below) = (text_color(sp.glyphs.iter().find(|g| g.len > 0)), text_color(sp.glyphs.iter().rev().find(|g| g.len > 0))); - let rule_color = |r: &designcraft_doc::Rule, text: &(String, f32)| { - if r.color == designcraft_doc::TEXT_COLOR { text.clone() } else { (r.color.clone(), r.tint) } - }; - let drop_cap = split_drop_cap(db, &mut sp.glyphs, drop_end, pp.drop_cap_lines, base_leading, base_cap, drop_grid, drop_align, drop_tabs); - // List labels take the default super/subscript settings. - let label_env = shape::TypeEnv { adv: Default::default(), ..env }; - let list_label = match pp.list_type { - // An empty paragraph has no bullet or number, and the numbering carries on past it. - designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None, - // A named list: carries on past other paragraphs (and from earlier stories). - designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { - Some(named_labels.get(pi).cloned().flatten().unwrap_or_else(|| pp.number_label(1))) - } - designcraft_doc::ListType::Numbers => { - list_counter.advance(&pp); - Some(pp.number_label_in(&list_counter)) - } - designcraft_doc::ListType::Bullets => Some(pp.bullet_label()), - designcraft_doc::ListType::None => { - list_counter = ListCounter::default(); - None - } - }; - // The label's glyphs at the paragraph start, and how many of them (up to its first tab) - // the list alignment places. - let mut label_aligned = 0usize; - let mut label_tab = false; - if let Some(label) = &list_label { - let mut chars = label_chars(doc, &pp, &base_chars); - // The number is drawn in the Digits of the paragraph's first run, so Persian text - // gets Persian numbers whether Digits is set on the paragraph style or on the text - // (unless the number's character style sets its own). - if chars.digits == base_chars.digits - && let Some((_, fmt)) = story.runs().find(|(rr, _)| rr.end > prange.start && rr.start < prange.end) - { - chars.digits = doc.styles.resolve_char(&base_chars, fmt).digits; - } - let n = prepend_label(db, &mut sp.glyphs, label, prange.start, &chars, label_env, &mut table); - for g in sp.glyphs.iter_mut().take(n) { - g.list_label = true; - } - label_aligned = sp.glyphs.get(..n).unwrap_or_default().iter().position(|g| g.ch == '\t').unwrap_or(n); - label_tab = label_aligned < n; - // Tab Position: a stop for the text after the label. - if let Some(t) = pp.list_tab.filter(|t| t.is_finite() && *t >= 0.0) - && !pp.tabs.iter().any(|s| (s.position - t).abs() < 1e-6) - { - pp.tabs.push(designcraft_doc::TabStop { position: t, align: TabAlign::Left, leader: String::new(), align_on: String::new() }); - pp.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); - } - } - if pi == 0 - && let Some(label) = &opts.label - { - prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, label_env, &mut table); - label_aligned = 0; - } - let list_align = match pp.list_type { - _ if label_aligned == 0 || pp.direction == designcraft_doc::TextDirection::RightToLeft => designcraft_doc::ListAlign::Left, - designcraft_doc::ListType::Bullets => pp.bullet_align, - _ => pp.number_align, - }; - boxes[pi] = ParaBox::of(doc, &pp); - let mut glyphs = sp.glyphs; - if !pp.composer.japanese() { - // Every IDML's root style carries a leading model and character alignment, which only - // the Japanese composers use. - for g in &mut glyphs { - g.leading_model = designcraft_doc::cjk::LeadingModel::Roman; - g.character_alignment = designcraft_doc::cjk::CharacterAlignment::Baseline; - } - } - let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text); + range: prange.clone(), + props: &pp, + frame: cur_frame, + opts, + var_story, + var_cache: &mut var_cache, + notes: &mut notes, + }); + let shaped = shape_paragraph(ParagraphShapeInput { + db, + doc, + story, + opts, + range: prange.clone(), + props: &mut pp, + para_chars: &base_chars, + frame: cur_frame, + paragraph: pi, + subst: &sub, + exceptions: &hyph_exceptions, + suppressed_hyphens: no_hyph.get(pi).map_or(&[], Vec::as_slice), + named_labels: &named_labels, + list_counter: &mut list_counter, + styles: &mut styles_tab, + missing_fonts: &mut missing_fonts, + frames, + columns: &cols, + cursor: &cur, + }); + boxes[pi] = shaped.para_box; + let ShapedParagraph { + sub_objects, + base_leading, + above, + below, + drop_cap, + label_aligned, + label_tab, + list_align, + glyphs, + bidi_text, + hyph_after, + base_size, + spacing, + mark_chars, + mark_leading, + last_line_mark, + para_box: _, + } = shaped; let bidi_info = unicode_bidi::BidiInfo::new( &bidi_text, Some(if pp.direction == designcraft_doc::TextDirection::RightToLeft { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }), ); - bidi::resolve_mirroring(&mut glyphs, &bidi_info); - apply_desired_spacing(&mut glyphs, &pp); - mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); - let mut hyph_after = - hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); - suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice)); - let base_size = base_chars.size; - let spacing = spacing_for(&pp, base_size); - // An empty paragraph takes its height from its paragraph mark's character format. - let mark_chars = if glyphs.is_empty() { - std::borrow::Cow::Owned(doc.styles.resolve_char(&base_chars, story.char_format_of(prange.start))) - } else { - std::borrow::Cow::Borrowed(&base_chars) - }; - let mark_leading = match mark_chars.leading { - designcraft_doc::Leading::Auto => mark_chars.size * pp.auto_leading, - designcraft_doc::Leading::Points(v) => v, - }; - // The paragraph mark is a character of the last line: its leading counts there like any - // other character's. It doesn't set the line's ascent or descent. A hidden or deleted - // mark takes no room. - let last_line_mark = story.text.get(prange.end..).filter(|t| t.starts_with('\n')).and_then(|_| { - let props = doc.styles.resolve_char(&base_chars, story.format_after(prange.end)); - let hidden = props.change == designcraft_doc::ChangeMark::Deleted - || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))); - (!hidden).then_some(match props.leading { - designcraft_doc::Leading::Auto => props.size * pp.auto_leading, - designcraft_doc::Leading::Points(v) => v, - }) - }); // A paragraph set across the columns moves on to the next frame where others move on to // the next column. let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1); span_paras[pi] = spanning; - // Span space applies where the paragraph spans more than one column; in a one-column frame - // a span changes nothing. - let spans_in = |fi: usize| spanning && cols.get(fi).is_some_and(|c| c.len() > 1); // Consecutive paragraphs with the same Split Columns settings share a split block; within // it, a paragraph moves on to the next sub-column. let split_cfg = SplitCfg::of(&pp); @@ -1395,652 +2959,140 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); // Rule above / shading track the paragraph's first line. - let mut g0 = 0usize; - let mut line_no = 0usize; - // Paragraph line number of the first line in the current column. - let mut col_first_line = 0usize; - // The story byte at which the last line set ended at an automatic hyphenation point. - let mut hyphen_at: Option = None; - let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); - let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span - let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; - // Paragraph gyoudori: the (frame, column) of the block and the grid line after it. - let mut para_block: Option<((usize, usize), f64)> = None; - // Segments of a line already placed (beside an object with text on both sides) that the - // next lines of the paragraph fill before a new line starts, and that line's baseline. - let mut carry: Vec<(f64, f64)> = Vec::new(); - let mut carry_baseline = 0.0; - // The grid line after the carried line's band (see `grid_end`). - let mut carry_grid: Option = None; - // Re-breaks after a line's segment turned out other than the breaker assumed. - let mut rebreaks = 0usize; - loop { - if cur.fi >= frames.len() { - if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { - balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); - pi = j; - continue 'paras; - } - // An empty last paragraph (the story ends with a return) isn't overset text, as in - // InDesign: nothing of it is left to show. - if prange.is_empty() && prange.start >= story.text.len() { - break 'paras; - } - out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); - break 'paras; + let placement = place_paragraph_lines( + PlacementShared { + doc, + story, + frames, + cols: &cols, + opts, + db, + out: &mut out, + styles_tab: &mut styles_tab, + cur: &mut cur, + notes: &mut notes, + snaps: &snaps, + note_snaps: ¬e_snaps, + list_counter: &mut list_counter, + force_col: &mut force_col, + line_cap: &mut line_cap, + no_hyph: &mut no_hyph, + trial: &mut trial, + limits: &mut limits, + balance_runs: &mut balance_runs, + balance_budget, + split_trial: &mut split_trial, + split_limits: &mut split_limits, + split_runs: &mut split_runs, + split_budget, + hyph_relays: &mut hyph_relays, + restores: &mut restores, + }, + ParagraphLines { + paragraph: pi, + range: prange.clone(), + props: &pp, + glyphs: &glyphs, + hyph_after: &hyph_after, + drop_cap: drop_cap.as_ref(), + base_chars: &base_chars, + mark_chars: &mark_chars, + mark_leading, + base_leading, + last_line_mark, + base_size, + spacing: &spacing, + bidi_info: &bidi_info, + sub_objects: &sub_objects, + spanning, + at_top, + paragraph_count: np, + info: &info, + label_aligned, + label_tab, + list_align, + }, + ); + let (line_no, col_first_line, first_line_rect) = match placement { + LinePlacement::Restart(j) => { + pi = j; + continue 'paras; } - let f = &frames[cur.fi]; - let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; - let col = cur.clip(base); - // A spanning paragraph isn't held to the balanced bottom of the columns above it. - let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; - let col = cur.split_rect(col); - // Estimate slots for the breaker with the paragraph's base leading. - // A shaped frame's slots change from line to line, so they are estimated with the - // band the lines are placed with (or a taller one, so no line outgrows its slot). - let (est_asc, est_band) = if f.outline.is_some() { - let (a, _, d, _) = line_metrics(&[], &glyphs, g0, mark_leading, mark_chars.size, db, &mark_chars); - (a, (a.max(base_chars.size * 0.8), d.max(base_chars.size * 0.25))) - } else { - (base_chars.size * 0.75, (base_chars.size * 0.8, 0.0)) - }; - let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5, em: base_chars.size * 0.88 }; - let est_first = - if carry.is_empty() { cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp) } else { carry_baseline + base_leading }; - let est_jump = cur.jump(f, base_leading, est_asc, est_tops); - let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, est_band, &glyphs[g0..], rtl, &carry); - let width = |j: usize| -> f64 { - let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1)); - // Beside the drop cap (in the column the paragraph starts in), its width; else the - // first-line indent. - let ind = pp.left_indent - + pp.right_indent - + match &drop_cap { - Some(dc) if line_no + j < dc.lines && col_first_line == 0 => dc.indent(), - _ if line_no + j == 0 => pp.first_line_indent, - _ => 0.0, - }; - (x1 - x0 - ind).max(1.0) - }; - let rest = &glyphs[g0..]; - let rest_h = &hyph_after[g0..]; - let breaks: Vec = if pp.composer.single_line() || has_tabs || rest.len() > 4000 { - // Where each line starts relative to the tab origin, as `layout_line` places it. - let tab = |j: usize, x: f64, i: usize| { - let x0 = slots.get(j).map_or(col.x0, |s| s.0); - let ind = pp.left_indent - + match &drop_cap { - // Beside a drop cap: at its start-side indent (the right side in RTL). - Some(dc) if line_no + j < dc.lines && col_first_line == 0 => { - if rtl { - 0.0 - } else { - dc.indent() - } - } - _ if line_no + j == 0 => pp.first_line_indent, - _ => 0.0, - }; - tab_advance(&pp.tabs, pp.left_indent, x0 + ind + x - col.x0, rest.get(i + 1..).unwrap_or_default()).0 - }; - breaker::greedy(rest, rest_h, &spacing, &width, &tab) - } else if pp.balance_ragged && !spacing.justify { - breaker::balanced(rest, rest_h, &spacing, &width) - } else { - breaker::knuth_plass(rest, rest_h, &spacing, &width) - }; - let mut moved = false; - for (k, b) in breaks.iter().enumerate() { - let (s, e) = (g0 + b.start, g0 + b.end); - let line_glyphs = &glyphs[s..e.max(s)]; - let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, mark_leading, mark_chars.size, db, &mark_chars); - let lead = match last_line_mark { - Some(m) if k + 1 == breaks.len() => lead.max(m), - _ => lead, - }; - let (reference, model) = cjk_line_reference(line_glyphs); - let advance = if cur.last_baseline.is_some() { cur.aki_below.unwrap_or(lead) } else { lead }; - let (mut x0, mut x1) = (col.x0, col.x1); - // A wrap segment that can't hold the line's first word whole stays empty and the - // text goes on in the next segment or line (a word wider than the column is split - // wherever it lands). - let ind = pp.left_indent + pp.right_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }; - let need = if f.exclusions.is_empty() && f.outline.is_none() { - 0.0 - } else { - let w = breaker::first_word_width(glyphs.get(s..).unwrap_or_default(), hyph_after.get(s..).unwrap_or_default(), &spacing); - if w > col.width() - ind { 0.0 } else { w } - }; - let holds = |&(a, b): &(f64, f64)| b - a - ind + 0.01 >= need; - // The line takes the next segment of a line already placed, the one the breaker - // set it for. - let mut continues = false; - let mut baseline = carry_baseline; - let mut grid_end = None; - if let Some(i) = carry.iter().position(holds) { - continues = i == 0; - grid_end = carry_grid; - (x0, x1) = carry.get(i).copied().unwrap_or((x0, x1)); - carry.drain(..=i); - } else { - carry.clear(); - baseline = cur.next_baseline(f, col, advance, asc, tops, &pp); - if cur.last_baseline.is_some() { - baseline += cur.last_reference - reference; - } - // Baseline grid: grid alignment and gyoudori (see `grid`). - let grid = f.grid.filter(|&(_, inc)| { - inc > 0.0 - && inc.is_finite() - && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0)) - }); - if let Some((g_start, inc)) = grid { - let n = pp.grid_gyoudori.min(grid::MAX_GYOUDORI); - match para_block.filter(|(at, _)| line_no > 0 && *at == (cur.fi, cur.col)) { - // Inside a paragraph-gyoudori block, lines after the first keep their leading. - Some((_, end)) => grid_end = Some(end), - None => { - let r = grid::reference_offset(line_glyphs, pp.grid_reference); - let block = n > 0 && pp.paragraph_gyoudori && line_no == 0; - let span = if block { - // From this line's reference point to the paragraph's last line's. - let aki_below = |model, lead| (model == designcraft_doc::cjk::LeadingModel::AkiBelow).then_some(lead); - let (mut down, mut prev, mut prev_aki, mut last_r) = (0.0, reference, aki_below(model, lead), r); - for (j, nb) in breaks.iter().enumerate().skip(k + 1) { - let (ns, ne) = (g0 + nb.start, g0 + nb.end); - let lg = glyphs.get(ns..ne.max(ns)).unwrap_or_default(); - let (_, _, _, nlead) = line_metrics(lg, &glyphs, ns, mark_leading, mark_chars.size, db, &mark_chars); - let nlead = match last_line_mark { - Some(m) if j + 1 == breaks.len() => nlead.max(m), - _ => nlead, - }; - let (nref, nmodel) = cjk_line_reference(lg); - down += prev_aki.unwrap_or(nlead) + prev - nref; - prev = nref; - prev_aki = aki_below(nmodel, nlead); - last_r = grid::reference_offset(lg, pp.grid_reference); - } - (down + last_r - r).max(0.0) - } else { - 0.0 - }; - let lines = if block { grid::lines_for(span, inc, n) } else { n.max(1) }; - let free = cur.grid_next.filter(|(b, _)| cur.last_baseline == Some(*b)).map(|(_, y)| y); - let band = grid::Band { natural: baseline + r, free, pending: cur.pending, fixed: n > 0, lines, span }; - let (y, end) = grid::place((g_start, inc), pp.grid_reference, band); - baseline = y - r; - grid_end = Some(end); - if block { - para_block = Some(((cur.fi, cur.col), end)); - } - } - } - } - // A line a wrap pushes down moves by whole grid lines, keeping its place in its band. - let on_grid = baseline; - let snap = |y: f64| grid.map_or(y, |(_, inc)| on_grid + ((y - on_grid) / inc - 1e-6).ceil().max(0.0) * inc); - // Wrap: push the line down to the first position with a slot (back on the grid). - // Segments after the first are carried to the next lines of the paragraph. - if !f.exclusions.is_empty() || f.outline.is_some() { - let jump = cur.jump(f, advance, asc, tops); - let line_start = glyphs.get(s).map_or(prange.start, |g| g.byte); - let mut tries = 0; - loop { - let Some((nb, segs)) = wrap_slot(f, col, baseline, asc, desc, jump, base_size, rtl, line_start, &holds) else { - // No room in this column. - baseline = baseline.max(col.y1) + 1.0; - carry.clear(); - break; - }; - let pushed = nb > baseline; - baseline = nb; - if let Some((&first, rest)) = segs.split_first() { - (x0, x1) = first; - carry = rest.to_vec(); - } - tries += 1; - if !pushed || grid.is_none() || tries > 4000 { - break; - } - baseline = snap(baseline); - } - } - grid_end = grid_end.map(|end| end + baseline - on_grid); - carry_baseline = baseline; - carry_grid = grid_end; - } - // The breaker set this line for another segment width: re-break the rest of the - // paragraph from here, starting with this line's segments as they are. A pass - // re-breaks at its first line only for a new line, whose segments it carries, so - // every re-break makes progress; their number is capped too. - // Past the estimated slots the breaker assumes the whole column. Check that - // fallback too: narrow wrap segments may need more lines than the estimate. - let (estimated_x0, estimated_x1) = slots.get(k).copied().unwrap_or((col.x0, col.x1)); - if (k > 0 || !continues) && rebreaks < 256 && ((estimated_x1 - estimated_x0) - (x1 - x0)).abs() > 0.5 { - rebreaks += 1; - carry.insert(0, (x0, x1)); - g0 = s; - moved = true; - break; - } - let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line; - // Footnotes referenced on this line need room at the bottom of the column too - // (a line at the top of a column is set anyway). - let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() }; - let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); - let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts); - let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; - // A horizontal text frame's bottom inset limits the last baseline, not - // the font's descender box. Descenders remain in the line metrics and may - // hang below that edge. Paths, vertical text, inline objects and text above - // reserved footnotes keep the full extent to prevent collisions. - let fit_descent = if f.opts.path.is_some() - || f.vertical - || reserve > 0.0 - || line_glyphs.iter().any(|g| sub_objects.get(&g.byte).is_some_and(|o| !o.custom)) - { - desc - } else { - 0.0 - }; - let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped; - if !fits - && !pp.hyph_across_column - && line_no > col_first_line - && let Some(at) = hyphen_at - && hyph_relays < story.text.len().saturating_add(64).saturating_mul(restores.saturating_add(1)) - { - hyph_relays += 1; - no_hyph[pi].push(at); - rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); - continue 'paras; - } - if !fits { - if cur.can_next_sub() { - g0 = s; - cur.next_sub(); - col_first_line = line_no; - carry.clear(); - moved = true; - break; - } - // A split block that doesn't fit above its trial bottom: re-lay it lower. - if let Some(key) = cur.split_key() - && limit_of(&split_limits, key).is_some_and(f64::is_finite) - { - let j = if split_trial.as_ref().is_some_and(|t| t.key == key) { - trial_failed(&mut split_trial, &mut split_limits, split_runs < split_budget) - } else { - set_limit(&mut split_limits, key, Some(f64::INFINITY)); - Some(key.2) - }; - if let Some(j) = j { - split_runs += 1; - rewind( - j, - &snaps, - ¬e_snaps, - &mut notes, - &mut out, - &mut cur, - &mut list_counter, - &mut force_col, - &mut line_cap, - &mut no_hyph, - ); - pi = j; - continue 'paras; - } - } - // Keep options don't apply between a split block's lines. - if !capped && cur.split.is_none_or(|s| s.part_lines == 0) { - let ctx = KeepCtx { - pi, - line_no, - in_col: line_no - col_first_line, - started_here: col_first_line == 0, - at_top, - remaining: breaks.len() - k, - here: (cur.fi, cur.col), - }; - let action = if restores < (4 * np + 64) * (balance_runs + 1) { - keep_violation(&ctx, &pp, &info, &force_col, &line_cap) - } else { - None - }; - if let Some(action) = action { - restores += 1; - let j = match action { - KeepAction::Force(j) => { - force_col[j] = true; - j - } - KeepAction::Cap(j, n) => { - line_cap[j] = Some(n); - j - } - }; - rewind( - j, - &snaps, - ¬e_snaps, - &mut notes, - &mut out, - &mut cur, - &mut list_counter, - &mut force_col, - &mut line_cap, - &mut no_hyph, - ); - pi = j; - continue 'paras; - } - } - // Next column / frame; re-break the rest of the paragraph there. - g0 = s; - next_column(&mut cur); - col_first_line = line_no; - carry.clear(); - moved = true; - break; - } - // A drop cap sits at the start-side indent; the first-line indent doesn't move it. - let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::indent); - let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 }; - let (ind_l, ind_r) = match beside { - Some(w) if rtl => (pp.left_indent, pp.right_indent + w), - Some(w) => (pp.left_indent + w, pp.right_indent), - // The first-line indent is at the paragraph's start side, which is the right - // one in a right-to-left paragraph: applied on the left it moved the line the - // wrong way, out of the frame for the hanging indent a numbered list uses - // (#1448). - None if rtl => (pp.left_indent, pp.right_indent + first_line), - None => (pp.left_indent + first_line, pp.right_indent), - }; - let lx0 = x0 + ind_l; - let lx1 = x1 - ind_r; - let last = k + 1 == breaks.len(); - // The break character that ends this line, if any (it is the line's last glyph). - let brk = if b.forced && e > s { - e.checked_sub(1).and_then(|i| glyphs.get(i)).map(|g| g.ch).filter(|&c| breaker::is_forced(c)) - } else { - None - }; - // A column, frame or page break ends the line as a paragraph's last line (last-line - // alignment); a justified line ended by a forced line break stays justified. - let ends_para = last || brk.is_some_and(story::is_break_char); - let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; - let (mut placed, end_x, ratio) = - layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); - let end_x = if line_no == 0 && s == 0 { align_label(&mut placed, label_aligned, label_tab, list_align, end_x) } else { end_x }; - // Warichu runs before ruby so a reading is placed over the stacked note, and before - // the trimmed spaces and the drop cap join the line, so neither is stacked. - let end_x = end_x + warichu::place(&styles_tab, &mut placed); - // The spaces the break trimmed off the end follow the line undrawn, so the caret - // and the selection reach them; alignment and justification ignore them. - if pp.direction != designcraft_doc::TextDirection::RightToLeft && !placed.iter().any(|g| g.rtl) { - let mut x = end_x; - for g in glyphs.get(e..(g0 + b.next).min(glyphs.len())).unwrap_or(&[]).iter().filter(|g| g.is_space()) { - placed.push(PlacedGlyph { visible: false, ..place(g, x) }); - x += g.adv; - } - } - let mut drop_box = None; - if line_no == 0 - && let Some(dc) = &drop_cap - { - let at = if rtl { x1 - pp.right_indent - dc.width } else { x0 + pp.left_indent - dc.lsb }; - placed.splice(0..0, place_drop_cap(dc, at, rtl)); - drop_box = Some(DropCapBox { - end: dc.end, - baseline: baseline + dc.drop, - // From its line's top, or its own top when it rises above that line. - rect: Rect::new(at, (baseline - asc).min(baseline + dc.drop - dc.cap), at + dc.width, baseline + dc.drop + desc), - }); - } - ruby::annotate(db, &mut styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical); - kenten::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); - let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) }; - let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end); - let range_start = if line_no == 0 { prange.start } else { range_start }; - let ft = &mut out.frames[cur.fi]; - if line_no == 0 { - first_line_rect = Some((cur.fi, baseline, asc, x0)); - } - if let Some(sb) = cur.split.as_mut() { - sb.line0.get_or_insert(ft.lines.len()); - sb.sub_lines += 1; - sb.part_lines += 1; - } - ft.lines.push(Line { - column: cur.col as u32, - baseline, - x0, - x1, - ascent: asc, - descent: desc, - leading: lead, - range: range_start..range_end, - para: pi, - glyphs: placed, - hyphenated: b.hyphen, - first_in_para: line_no == 0, - last_in_para: last, - end_x, - spacing: ratio, - hj: hj_severity(ratio, pp.word_space_min, pp.word_space_max), - keep_violation: false, - drop_cap: drop_box, - }); - let placed_notes = !line_notes.is_empty(); - for k in line_notes { - notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); - } - // Notes spanning the columns take room from the columns before this one too: re-lay - // the frame with that room left. - if placed_notes { - let r = notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts); - if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) - && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) - { - rewind( - j, - &snaps, - ¬e_snaps, - &mut notes, - &mut out, - &mut cur, - &mut list_counter, - &mut force_col, - &mut line_cap, - &mut no_hyph, - ); - pi = j; - continue 'paras; - } - } - if !sub_objects.is_empty() { - let ft = &mut out.frames[cur.fi]; - let li = ft.lines.len() - 1; - let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max); - let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 }; - for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) { - if let Some(o) = sub_objects.get(&g.byte) - && let Some(source) = story.objects.get(o.index) - { - ft.objects.push(PlacedObject { - index: o.index, - object: Arc::clone(source), - origin: Point::ZERO, - size: (o.w, o.h), - line: li, - x: g.x, - text_ascent, - }); - } - } - } - cur.last_baseline = Some(baseline); - cur.last_reference = reference; - cur.aki_below = (model == designcraft_doc::cjk::LeadingModel::AkiBelow).then_some(lead); - cur.last_descent = desc; - cur.pending = 0.0; - cur.grid_next = grid_end.map(|end| (baseline, end)); - hyphen_at = - e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); - line_no += 1; - // A column / frame / page break inside the paragraph: the rest of it continues in the - // new column/frame/page, re-broken there. (A break that ends the paragraph moves the - // next paragraph instead.) - if b.forced - && k + 1 < breaks.len() - && let Some(start) = brk.and_then(break_start) - { - go(&mut cur, start); - col_first_line = line_no; - g0 += b.next; - carry.clear(); - moved = true; - break; - } - // An own-story anchored object anchored in this line wraps the lines after it, - // which were broken without it: break them again. - if k + 1 < breaks.len() - && f.exclusions.iter().any(|ex| { - ex.after.is_some_and(|a| (range_start..range_end).contains(&a)) - && ex.rect.x0 < col.x1 - && ex.rect.x1 > col.x0 - && ex.rect.y1 > baseline - && ex.rect.y0 < col.y1 - }) - { - g0 += b.next; - moved = true; - break; - } - } - if !moved { - break; - } - } - info.push(ParaInfo { - start: start_at, + LinePlacement::Overset => break 'paras, + LinePlacement::StoryComplete => break 'paras, + LinePlacement::Complete { line_no, col_first_line, first_line_rect } => (line_no, col_first_line, first_line_rect), + }; + let ends_split = match finish_paragraph(ParagraphFinishInput { + doc, + story, + frames, + cols: &cols, + out: &mut out, + cur: &mut cur, + notes: &mut notes, + snaps: &snaps, + note_snaps: ¬e_snaps, + list_counter: &mut list_counter, + force_col: &mut force_col, + line_cap: &mut line_cap, + no_hyph: &mut no_hyph, + split_trial: &mut split_trial, + split_limits: &mut split_limits, + split_runs: &mut split_runs, + split_budget, + paragraph: pi, + paragraph_count: np, + props: &pp, + info: &mut info, + start_at, at_top, - end: (cur.fi, cur.col), - lines: line_no, - lines_in_end_col: line_no - col_first_line, - keep_with_next: pp.keep_with_next, - keep_together: pp.keep_lines_together, - keep_all: pp.keep_all_lines, - keep_first: pp.keep_first as usize, - keep_last: pp.keep_last as usize, - }); - // The columns below a spanning paragraph start under it. - if spanning - && let Some(ft) = out.frames.get(cur.fi) - && let Some(l) = ft.lines.last().filter(|l| l.para == pi) - { - let above = Above { - top: l.baseline + l.descent, - baseline: l.baseline, - descent: l.descent, - reference: cur.last_reference, - aki_below: cur.aki_below, - pending: if spans_in(cur.fi) { pp.space_after.max(pp.span_space_after) } else { pp.space_after }, - }; - cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None }; - } - // Rules and shading for the paragraph. - if let Some((fi, bl, _, _)) = first_line_rect { - let ft = &mut out.frames[fi]; - if pp.rule_above.on { - let r = &pp.rule_above; - // The offset runs from the first line's baseline up to the rule's bottom edge. - let y = bl - r.offset; - let col = ft - .lines - .iter() - .find(|l| l.para == pi && l.first_in_para) - .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) - .unwrap_or((0.0, 0.0)); - let (color, tint) = rule_color(r, &above); - ft.decos.push(Deco { - rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), - color, - tint, - ..Deco::default() - }); + end_line: line_no, + end_column_first_line: col_first_line, + first_line_rect, + above: &above, + below: &below, + spanning, + }) { + ParagraphFinish::Done(ends_split) => ends_split, + ParagraphFinish::Restart(j) => { + pi = j; + continue 'paras; } - } - if pp.rule_below.on - && let Some(ft) = out.frames.get_mut(cur.fi.min(frames.len().saturating_sub(1))) - && let Some(l) = ft.lines.iter().rev().find(|l| l.para == pi) - { - let r = &pp.rule_below; - let y = l.baseline + r.offset; - let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; - let (color, tint) = rule_color(r, &below); - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); - } - // The end of a split block: text after it continues below its deepest sub-column, which - // are balanced first. A block that ends the story fills its sub-columns in turn. - let mut ends_split = false; - if let Some(sb) = cur.split { - let next = - story.paras.get(pi + 1).filter(|_| story.para_table(pi + 1).is_none()).and_then(|p| SplitCfg::of(&doc.styles.resolve_para(p).0)); - if next != Some(sb.cfg) { - cur.split = None; - ends_split = true; - let part = out.frames.get(cur.fi).and_then(|ft| { - let lines = ft.lines.get(sb.line0?..)?; - let top = lines.first().map(|l| l.baseline - l.ascent)?; - let deepest = lines.iter().map(|l| (l.baseline, l.descent)).max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)))?; - Some((sb.top.unwrap_or(top), deepest)) - }); - if pi + 1 < np - && let Some((top, (baseline, descent))) = part - { - let key = (cur.fi, cur.col, sb.para); - let active = split_trial.as_ref().is_some_and(|t| t.key == key); - if !active && let Some(t) = split_trial.take() { - set_limit(&mut split_limits, t.key, None); - } - if sb.part_lines > 1 && (active || limit_of(&split_limits, key).is_none() && split_runs < split_budget) { - let bottom = baseline + descent; - let mut t = split_trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false }); - // This layout fits: its bottom is the best so far. - t.hi = t.hi.min(bottom); - let next = if t.done { t.hi } else { t.next() }; - set_limit(&mut split_limits, key, Some(next)); - if !t.done { - split_trial = Some(t); - split_runs += 1; - rewind( - key.2, - &snaps, - ¬e_snaps, - &mut notes, - &mut out, - &mut cur, - &mut list_counter, - &mut force_col, - &mut line_cap, - &mut no_hyph, - ); - pi = key.2; - continue 'paras; - } - } - cur.last_baseline = Some(baseline); - cur.last_descent = descent; - } - } - } - cur.pending += if spans_in(cur.fi) || ends_split { pp.space_after.max(pp.span_space_after) } else { pp.space_after }; + }; + let spans_in_current_frame = spanning && cols.get(cur.fi).is_some_and(|c| c.len() > 1); + cur.pending += if spans_in_current_frame || ends_split { pp.space_after.max(pp.span_space_after) } else { pp.space_after }; pi += 1; } - notes.finish(doc, story, frames, &cols, &mut out); + finish_composition(FinishInput { + doc, + story, + frames, + cols: &cols, + notes: &mut notes, + out: &mut out, + boxes: &boxes, + span_paras: &span_paras, + split_paras: &split_paras, + styles_tab, + }) +} + +struct FinishInput<'a> { + doc: &'a Document, + story: &'a Story, + frames: &'a [FrameSpec], + cols: &'a [Vec], + notes: &'a mut Notes, + out: &'a mut ComposedStory, + boxes: &'a [Option], + span_paras: &'a [bool], + split_paras: &'a [bool], + styles_tab: Vec, +} + +fn finish_composition(input: FinishInput<'_>) -> ComposedStory { + let FinishInput { doc, story, frames, cols, notes, out, boxes, span_paras, split_paras, styles_tab } = input; + notes.finish(doc, story, frames, cols, out); // Ranges, content heights and vertical justification. for (fi, ft) in out.frames.iter_mut().enumerate() { let f = &frames[fi]; @@ -2129,7 +3181,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // Paragraph borders and shading (the part of a paragraph that fits keeps its box), and column // rules, once vertical justification has put the lines in place. - parabox::draw(&mut out, &boxes, frames); + parabox::draw(out, boxes, frames); for (ft, f) in out.frames.iter_mut().zip(frames) { if f.opts.column_rule { let rects = column_rule_rects(&ft.columns, &ft.lines, &f.opts); @@ -2138,8 +3190,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } } out.styles = styles_tab; - mark_keep_violations(doc, story, &mut out); - out + mark_keep_violations(doc, story, out); + std::mem::take(out) } /// The columns a spanning paragraph is set across, from `col`'s top to its bottom. @@ -2199,19 +3251,28 @@ fn mark_keep_violations(doc: &Document, story: &Story, out: &mut ComposedStory) const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0; /// Text Frame Options › Column Rules: one bar per gutter, `column_rule_weight` wide, centred -/// between the facing edges of the two columns and moved by `column_rule_offset`, from the -/// columns' top to their bottom (the text area, inside the insets) shortened by the rule's top -/// and bottom insets. Works in the composed space, so right-to-left and vertical frames (whose -/// columns are laid out in the turned box) get their rules between the columns too. +/// between the facing edges of the two columns and moved by `column_rule_offset`, from the top of +/// the frame's top line (its ascent) to the bottom of its bottom line (its descent), as InDesign +/// draws them, shortened by the rule's top and bottom insets (a negative inset extends it). Works +/// in the composed space, so right-to-left and vertical frames (whose columns are laid out in the +/// turned box) get their rules between the columns too. /// /// A paragraph that spans columns interrupts the rule: the bar stops at the top of its first line -/// and resumes below its last line. Nothing is drawn for a single column or a weight that is not a -/// positive finite number. +/// and resumes below its last line. Nothing is drawn for a frame without lines, a single column or +/// a weight that is not a positive finite number. pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptions) -> Vec { let weight = opts.column_rule_weight; if !weight.is_finite() || weight <= 0.0 || columns.len() < 2 { return Vec::new(); } + let (text_top, text_bottom) = lines + .iter() + .map(|l| (l.baseline - l.ascent, l.baseline + l.descent)) + .filter(|(t, z)| t.is_finite() && z.is_finite()) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(t, z), (lt, lz)| (t.min(lt), z.max(lz))); + if text_bottom < text_top { + return Vec::new(); + } let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; let (offset, top_inset, bottom_inset) = (finite(opts.column_rule_offset), finite(opts.column_rule_top_inset), finite(opts.column_rule_bottom_inset)); @@ -2223,7 +3284,7 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio for pair in cols.windows(2) { let [a, b] = [pair[0], pair[1]]; let cx = (a.x1 + b.x0) / 2.0; - let (top, bottom) = (a.y0.max(b.y0) + top_inset, a.y1.min(b.y1) - bottom_inset); + let (top, bottom) = (text_top + top_inset, text_bottom - bottom_inset); if bottom <= top { continue; } @@ -2258,8 +3319,22 @@ pub fn column_rule_rects(columns: &[Rect], lines: &[Line], opts: &TextFrameOptio out } +/// Optical margin alignment for a paragraph of `story`: (on, the size the hang is set for). +/// Ignore Optical Margin turns it off; the story's setting, when on, applies to every other +/// paragraph; otherwise the paragraph's own switch hangs at each glyph's own size. +fn optical_for(pp: &ParaProps, story: &Story) -> (bool, Option) { + if pp.ignore_optical_margin { + return (false, None); + } + match story.optical_margin.hang_size() { + Some(size) => (true, Some(size)), + None => (pp.optical_margin, None), + } +} + /// Breaker parameters from the paragraph's settings. -fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { +fn spacing_for(pp: &ParaProps, base_size: f64, story: &Story) -> Spacing { + let (optical, optical_size) = optical_for(pp, story); Spacing { justify: pp.align.is_justified(), word_min: pp.word_space_min, @@ -2277,7 +3352,8 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { // The zone is the Single-line Composer's (unjustified text only), as in InDesign: the // Paragraph Composer weighs every break of the paragraph instead. hyph_zone: if pp.align.is_justified() || !pp.composer.single_line() { 0.0 } else { pp.hyph_zone }, - optical: pp.optical_margin, + optical, + optical_size, korean_char_breaks: pp.korean_char_breaks, kinsoku_priority: match pp.kinsoku_type.as_str() { "KinsokuPushInFirst" => 1, @@ -3326,6 +4402,143 @@ fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], left_indent: f64, abs: (w.max(0.0), stop) } +/// Geometry needed to measure tabs on a line with hanging edge glyphs. +struct TabMeasure { + x0: f64, + x1: f64, + tab_origin: f64, + hl: f64, + optical_right: f64, + cjk_right: f64, + regular_hang: bool, +} + +/// Measure tabs left to right while preserving their stops across hanging margins. +fn layout_tabs(line: &mut [Glyph], pp: &ParaProps, geometry: TabMeasure) -> (Vec<(usize, String)>, Option<(usize, bool)>) { + let TabMeasure { x0, x1, tab_origin, hl, optical_right, cjk_right, regular_hang } = geometry; + let measure = x1 - x0; + let mut x = -hl; + let mut i = 0; + let mut leaders: Vec<(usize, String)> = Vec::new(); + // The line's last tab and whether it is a left tab (only text after a left tab justifies). + let mut last_tab: Option<(usize, bool)> = None; + while i < line.len() { + if line[i].ch == '\t' { + let (w, stop) = tab_advance(&pp.tabs, pp.left_indent, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default()); + if let Some(t) = stop + && !t.leader.is_empty() + { + leaders.push((i, t.leader.clone())); + } + line[i].adv = w; + // A default tab stop is a left tab. + last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left))); + } else if line[i].ch == story::RIGHT_INDENT_TAB { + // Use the first stop past the right margin, or the last stop at/before it. + // Choose the stop before checking its leader: an empty leader takes precedence too. + let right = x1 - tab_origin; + let stop = pp + .tabs + .iter() + .filter(|t| t.position > right) + .min_by(|a, b| a.position.total_cmp(&b.position)) + .or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position))); + if let Some(t) = stop.filter(|t| !t.leader.is_empty()) { + leaders.push((i, t.leader.clone())); + } + let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); + let hr = if regular_hang { optical_right } else { optical_right.max(cjk_right) }; + line[i].adv = (measure + hr - x - rest).max(0.0); + last_tab = Some((i, false)); + } + x += line[i].adv; + i += 1; + } + (leaders, last_tab) +} + +/// Resolve spine-relative alignment to a physical edge for this page. +fn page_alignment(align: Align, left_page: bool) -> Align { + match align { + Align::TowardsSpine => { + if left_page { + Align::Right + } else { + Align::Left + } + } + Align::AwayFromSpine => { + if left_page { + Align::Left + } else { + Align::Right + } + } + a => a, + } +} + +/// Allocate justified Arabic elongation before distributing the remaining space. +fn allocate_kashidas(line: &[Glyph], add: &mut [f64], pp: &ParaProps, seg: usize, extra: &mut f64) -> Vec<(usize, f64)> { + if !(*extra > 0.0 && pp.kashidas && pp.arabic_justification != "DefaultJustification") { + return Vec::new(); + } + let points: Vec = kashida_points(line).into_iter().filter(|&i| i >= seg).collect(); + if points.is_empty() { + return Vec::new(); + } + let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum()); + let per = total / points.len() as f64; + *extra -= total; + let kashidas: Vec<(usize, f64)> = points.into_iter().map(|i| (i, per)).collect(); + for &(i, length) in &kashidas { + add[i] += length; + } + kashidas +} + +/// Add stretched tatweel glyphs to the gaps allocated for Arabic elongation. +fn append_kashidas(line: &[Glyph], out: &mut Vec, kashidas: Vec<(usize, f64)>) { + for (i, l) in kashidas { + let first = (0..=i).rev().take_while(|&j| line[j].byte == line[i].byte).last().unwrap_or(i); + let (g, at) = (&line[i], out[first].x - line[first].dx - l); + let gid = designcraft_fonts::first_glyph(&g.face, &['\u{0640}']); + let w = g.face.advance(gid) * g.sx; + if gid == 0 || w <= 0.0 { + continue; + } + // One tatweel stretched over the gap (overlapping its neighbours a little). + let k = (l + 0.4) / w; + out.push(PlacedGlyph { + gid, + x: at - 0.2, + y: -g.shift, + adv: 0.0, + sx: g.sx * k, + len: 0, + generated_text: None, + upright: false, + tcy: None, + dx: 0.0, + ..place(g, at) + }); + } +} + +/// Average the actual word-space ratios for H&J highlighting. +fn word_space_ratio(line: &[Glyph], add: &[f64], spaces: &[usize]) -> f64 { + let mut ratio_sum = 0.0; + let mut ratio_n = 0usize; + for &i in spaces { + let g = &line[i]; + if matches!(g.ch, ' ' | shape::NBSP) && g.space > 0.0 { + ratio_sum += (g.adv + add[i]) / g.space; + ratio_n += 1; + } + } + if ratio_n > 0 { ratio_sum / ratio_n as f64 } else { 1.0 } +} + /// Position glyphs `s..e` within `[x0, x1]`; returns (glyphs, end x, word-space ratio). /// /// Justified lines distribute the difference to the measure in priority order: word spaces up to @@ -3359,58 +4572,23 @@ fn layout_line( let h = shape::hyphen_after(g); line.push(h); } - // Tabs: compute widths left to right. let measure = x1 - x0; - let mut x = 0.0; - let mut i = 0; - let mut leaders: Vec<(usize, String)> = Vec::new(); - // The line's last tab and whether it is a left tab (only text after a left tab justifies). - let mut last_tab: Option<(usize, bool)> = None; - while i < line.len() { - if line[i].ch == '\t' { - let (w, stop) = tab_advance(&pp.tabs, pp.left_indent, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default()); - if let Some(t) = stop - && !t.leader.is_empty() - { - leaders.push((i, t.leader.clone())); - } - line[i].adv = w; - // A default tab stop is a left tab. - last_tab = Some((i, stop.as_ref().is_none_or(|t| t.align == TabAlign::Left))); - } else if line[i].ch == story::RIGHT_INDENT_TAB { - // Use the first stop past the right margin, or the last stop at/before it. - // Choose the stop before checking its leader: an empty leader takes precedence too. - let right = x1 - tab_origin; - let stop = pp - .tabs - .iter() - .filter(|t| t.position > right) - .min_by(|a, b| a.position.total_cmp(&b.position)) - .or_else(|| pp.tabs.iter().filter(|t| t.position <= right).max_by(|a, b| a.position.total_cmp(&b.position))); - if let Some(t) = stop.filter(|t| !t.leader.is_empty()) { - leaders.push((i, t.leader.clone())); - } - let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); - line[i].adv = (measure - x - rest).max(0.0); - last_tab = Some((i, false)); - } - x += line[i].adv; - i += 1; - } - let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB); - // Optical margin alignment: the measure grows by the hang of the edge glyphs. + // Optical margin alignment and hanging punctuation: the glyphs at the line's edges hang + // outside the measure. A tab at an edge hangs nothing. + let is_tab = |g: &Glyph| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB; + let at_edge = |g: &&Glyph| is_tab(g) || (g.adv > 0.0 && !g.is_space()); + let first = line.iter().find(at_edge); + let lastg = line.iter().rev().find(at_edge); + let hl = first.map_or(0.0, |g| breaker::hang_left(g, sp)); + let optical_right = lastg.map_or(0.0, |g| breaker::hang_right(g, sp)); + let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); + let regular_hang = pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular; + let tab_measure = TabMeasure { x0, x1, tab_origin, hl, optical_right, cjk_right, regular_hang }; + let (leaders, last_tab) = layout_tabs(&mut line, pp, tab_measure); + // The measure grows by the hang of the edge glyphs. let (mut x0, mut measure) = (x0, measure); - if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab { - let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space()); - let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space()); - let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 }; - let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 }; - let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang); - let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular { - cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) - } else { - cjk_right - }; + if sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None { + let cjk_right = if regular_hang { cjk_right.min((line.iter().map(|g| g.adv).sum::() - measure).max(0.0)) } else { cjk_right }; let hr = optical_right.max(cjk_right); x0 -= hl; measure += hl + hr; @@ -3423,23 +4601,7 @@ fn layout_line( let seg_justifies = last_tab.is_none_or(|(_, left)| left); // Spaces inside a jidori run keep the run's fitted width, as in the breaker. let spaces: Vec = line.iter().enumerate().skip(seg).filter(|(_, g)| g.is_justify_space() && !g.locked_advance).map(|(i, _)| i).collect(); - let align = match pp.align { - Align::TowardsSpine => { - if left_page { - Align::Right - } else { - Align::Left - } - } - Align::AwayFromSpine => { - if left_page { - Align::Left - } else { - Align::Right - } - } - a => a, - }; + let align = page_alignment(pp.align, left_page); let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && seg_justifies; // A justified paragraph's last line may have been composed with shrunk spaces: shrink it too. let squeeze_last = !justify_this @@ -3451,19 +4613,7 @@ fn layout_line( let mut scale = vec![1.0; line.len()]; let mut offset = 0.0; // Kashidas: in justified Arabic, the joins of words take the extra length first. - let mut kashidas: Vec<(usize, f64)> = Vec::new(); - if justify_this && extra > 0.0 && pp.kashidas && pp.arabic_justification != "DefaultJustification" { - let points: Vec = kashida_points(&line).into_iter().filter(|&i| i >= seg).collect(); - if !points.is_empty() { - let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum()); - let per = total / points.len() as f64; - kashidas = points.into_iter().map(|i| (i, per)).collect(); - extra -= total; - for &(i, l) in &kashidas { - add[i] += l; - } - } - } + let kashidas = if justify_this { allocate_kashidas(&line, &mut add, pp, seg, &mut extra) } else { Vec::new() }; if (justify_this || squeeze_last) && (extra >= 0.0 || sp.kinsoku_priority != 3) { extra = mojikumi::distribute(line.get_mut(seg..).unwrap_or_default(), extra, add.get_mut(seg..).unwrap_or_default()); } @@ -3524,17 +4674,7 @@ fn layout_line( offset = 0.0; } } - // Word-space ratio actually used (vs the space width), for H&J highlighting. - let mut ratio_sum = 0.0; - let mut ratio_n = 0usize; - for &i in &spaces { - let g = &line[i]; - if matches!(g.ch, ' ' | shape::NBSP) && g.space > 0.0 { - ratio_sum += (g.adv + add[i]) / g.space; - ratio_n += 1; - } - } - let ratio = if ratio_n > 0 { ratio_sum / ratio_n as f64 } else { 1.0 }; + let ratio = word_space_ratio(&line, &add, &spaces); let mut out = Vec::with_capacity(line.len()); let mut x = x0 + offset; let bidi = bidi_info.has_rtl(); @@ -3571,30 +4711,7 @@ fn layout_line( tab_leader(g, leader, p.x, p.adv, tab_origin, &mut out); } } - for (i, l) in kashidas { - let first = (0..=i).rev().take_while(|&j| line[j].byte == line[i].byte).last().unwrap_or(i); - let (g, at) = (&line[i], out[first].x - line[first].dx - l); - let gid = designcraft_fonts::first_glyph(&g.face, &['\u{0640}']); - let w = g.face.advance(gid) * g.sx; - if gid == 0 || w <= 0.0 { - continue; - } - // One tatweel stretched over the gap (overlapping its neighbours a little). - let k = (l + 0.4) / w; - out.push(PlacedGlyph { - gid, - x: at - 0.2, - y: -g.shift, - adv: 0.0, - sx: g.sx * k, - len: 0, - generated_text: None, - upright: false, - tcy: None, - dx: 0.0, - ..place(g, at) - }); - } + append_kashidas(&line, &mut out, kashidas); (out, x, ratio) } @@ -4044,7 +5161,22 @@ fn prepend_label( let mut tmp = Story::new(StoryId(0)); tmp.insert(0, label); let styles = designcraft_doc::Styles::default(); - let shaped = shape::shape_para(db, &styles, &tmp, 0, 0..label.len(), base, env, &SubstCtx::default(), table, &[], &[], &[], (0, "")); + let shaped = shape::shape_para( + shape::ShapeInput { + db, + styles: &styles, + story: &tmp, + range: 0..label.len(), + para_chars: base, + auto_leading: env, + sub: &SubstCtx::default(), + nested: &[], + grep: &[], + lines: &[], + drop_cap: (0, ""), + }, + table, + ); let mut pre: Vec = shaped .glyphs .into_iter() diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 2a465fbfa..a385a9093 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -46,6 +46,14 @@ pub(crate) struct NoteMetrics { descent: f64, } +#[derive(Clone, Copy)] +pub(crate) struct NoteColumn<'a> { + pub(crate) frame_index: usize, + pub(crate) column_index: usize, + pub(crate) width: f64, + pub(crate) frame: &'a FrameSpec, +} + /// Height of footnotes stacked baseline to baseline, from the top of the footnote area to the /// last footnote's descent. fn stack_height<'a>(notes: impl IntoIterator, space_between: f64) -> f64 { @@ -130,7 +138,7 @@ impl Notes { self.num = o.start_at; } self.numbers[k] = self.num; - self.num += 1; + self.num = self.num.saturating_add(1); sub.notes.insert(self.refs[k], o.label(self.numbers[k], false)); } sub.note_position = o.ref_position; @@ -180,6 +188,7 @@ impl Notes { running: opts.running.clone(), label: Some(label), xrefs: opts.xrefs.clone(), + placed: Vec::new(), }; let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts); let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default(); @@ -193,16 +202,16 @@ impl Notes { } /// Height the footnotes of column (fi, col) take, with `extra` footnotes added. - #[allow(clippy::too_many_arguments)] - pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 { + pub fn reserve(&mut self, doc: &Document, column: NoteColumn<'_>, extra: &[usize], opts: &ComposeOptions) -> f64 { let o = &doc.footnote_options; let (space_between, space_before) = (o.space_between, o.space_before); - let mut ms: Vec = self.placed.iter().filter(|p| self.same_area(p, fi, col)).map(|p| p.m).collect(); + let mut ms: Vec = + self.placed.iter().filter(|p| self.same_area(p, column.frame_index, column.column_index)).map(|p| p.m).collect(); for &k in extra { - ms.push(self.compose(doc, k, w, f, opts).1); + ms.push(self.compose(doc, k, column.width, column.frame, opts).1); } let r = if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before }; - if self.span { r.max(self.floor.get(&fi).copied().unwrap_or(0.0)) } else { r } + if self.span { r.max(self.floor.get(&column.frame_index).copied().unwrap_or(0.0)) } else { r } } /// After spanning notes were placed in column `col` of frame `fi`: whether a line of an earlier @@ -220,12 +229,12 @@ impl Notes { hit } - pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) { + pub fn place(&mut self, doc: &Document, k: usize, column: NoteColumn<'_>, opts: &ComposeOptions) { if self.placed.iter().any(|p| p.k == k) { return; } - let (text, m) = self.compose(doc, k, w, f, opts); - self.placed.push(Placed { k, fi, col, m, text }); + let (text, m) = self.compose(doc, k, column.width, column.frame, opts); + self.placed.push(Placed { k, fi: column.frame_index, col: column.column_index, m, text }); } /// Stack each column's footnotes against its bottom, rule above the first. diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index d65460949..b09ef908e 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -9,9 +9,42 @@ use designcraft_doc::{GrepStyle, NestedStyle, NestedUntil}; use crate::grep::{Grep, GrepOptions}; +/// Patterns kept compiled at once. A document's GREP styles are few; the cache grew without +/// bound across every pattern a session ever composed (#1598). +const MAX_CACHED_PATTERNS: usize = 64; + thread_local! { - /// Compiled GREP styles by pattern; None for one that doesn't compile or gave up on a search. - static PATTERNS: RefCell>> = RefCell::new(HashMap::new()); + /// Compiled GREP styles by pattern, with the tick they were last used at; `None` for one that + /// doesn't compile or gave up on a search. Bounded to [`MAX_CACHED_PATTERNS`], least recently + /// used evicted first. + static PATTERNS: RefCell, u64)>> = RefCell::new(HashMap::new()); + /// Counts cache uses, so the least recently used entry can be found. + static TICK: RefCell = const { RefCell::new(0) }; +} + +/// The compiled pattern for `pattern`, compiling it on first use and evicting the least recently +/// used entry once the cache is full. `f` is given `None` when the pattern does not compile. +fn with_pattern(pattern: &str, f: impl FnOnce(&mut Option) -> R) -> R { + let now = TICK.with(|t| { + let mut t = t.borrow_mut(); + *t += 1; + *t + }); + PATTERNS.with(|p| { + let mut p = p.borrow_mut(); + if !p.contains_key(pattern) + && p.len() >= MAX_CACHED_PATTERNS + && let Some(oldest) = p.iter().min_by_key(|(_, (_, used))| *used).map(|(k, _)| k.clone()) + { + p.remove(&oldest); + } + let slot = p.entry(pattern.to_string()).or_insert_with(|| { + let re = Grep::new(pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {pattern:?} skipped: {e}")).ok(); + (re, now) + }); + slot.1 = now; + f(&mut slot.0) + }) } fn is_event(u: &NestedUntil, c: char, prev: Option, next: Option) -> bool { @@ -71,14 +104,15 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], if g.style.is_empty() || g.pattern.is_empty() { continue; } - PATTERNS.with(|p| { - let mut p = p.borrow_mut(); - let slot = p.entry(g.pattern.clone()).or_insert_with(|| { - Grep::new(&g.pattern, GrepOptions::default()).map_err(|e| log::warn!("GREP style {:?} skipped: {e}", g.pattern)).ok() - }); + with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; - match re.find_all(text.get(range.start..end).unwrap_or(""), usize::MAX) { - Ok(ms) => out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))), + match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { + Ok(ms) => { + if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { + log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); + } + out.extend(ms.into_iter().filter(|m| !m.is_empty()).map(|m| (range.start + m.start..range.start + m.end, g.style.clone()))); + } Err(e) => { log::warn!("GREP style {:?} disabled: {e}", g.pattern); *slot = None; @@ -93,6 +127,40 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], mod tests { use super::*; + /// A pattern that can match nothing still styles a paragraph longer than the cap: empty + /// matches don't count toward it. + #[test] + fn empty_matches_do_not_use_up_the_grep_cap() { + let text = format!("{}a", "b".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 100)); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a*".into() }], 0); + assert_eq!(o.len(), 1, "the one non-empty match at the end is styled"); + } + + /// A GREP style's scan is bounded two ways (#1598): the matches one paragraph can take, and + /// the patterns kept compiled at once. + #[test] + fn a_grep_scan_and_the_pattern_cache_are_bounded() { + // More matches in one paragraph than the cap: the scan stops with what it has instead of + // paying the backtrack limit again for each one. + let text = "a ".repeat(crate::grep::MAX_MATCHES_PER_PARAGRAPH + 500); + let o = overlays(&text, 0..text.len(), &[], &[GrepStyle { style: "A".into(), pattern: "a".into() }], 0); + assert_eq!(o.len(), crate::grep::MAX_MATCHES_PER_PARAGRAPH, "the scan stops at the cap"); + + // The cache holds at most MAX_CACHED_PATTERNS, and the one just used is not the one + // dropped: a document's own styles stay compiled however many patterns a session saw. + PATTERNS.with(|p| p.borrow_mut().clear()); + for i in 0..MAX_CACHED_PATTERNS + 20 { + let pattern = format!("x{i}y"); + overlays("x1y", 0..3, &[], &[GrepStyle { style: "S".into(), pattern }], 0); + } + let (len, has_last) = PATTERNS.with(|p| { + let p = p.borrow(); + (p.len(), p.contains_key(&format!("x{}y", MAX_CACHED_PATTERNS + 19))) + }); + assert_eq!(len, MAX_CACHED_PATTERNS, "the cache is bounded"); + assert!(has_last, "the most recently used pattern is still cached"); + } + #[test] fn nested_and_grep_ranges() { let text = "Q: what is 42? A drop of 7 words.\n".to_string(); diff --git a/crates/compose/src/placed.rs b/crates/compose/src/placed.rs new file mode 100644 index 000000000..200ff6e3c --- /dev/null +++ b/crates/compose/src/placed.rs @@ -0,0 +1,84 @@ +//! Where anchored text frames sit on their spread. +//! +//! An anchored frame is not a spread item: its host story's composition places it on a line. Its +//! own story is composed against that placement (page, page side, page rect, baseline grid), so +//! the host story is composed first. + +use std::sync::Arc; + +use designcraft_doc::{Document, Item, ItemId, SpreadRef, StoryId}; +use designcraft_geom::Affine; + +use crate::{ComposedStory, FrameText}; + +/// Hosts nested deeper than this (a note anchored in a note anchored in …, or stories anchored in +/// each other in a cycle) are composed where they are stored. +pub(crate) const MAX_DEPTH: usize = 8; + +/// An anchored item's parent space on its spread, where its host story's composition placed it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub spread: SpreadRef, + /// The item's parent space → spread space. + pub xf: Affine, +} + +/// The placements of `sid`'s anchored frames, from their host stories composed by `host`. +pub(crate) fn placements(doc: &Document, sid: StoryId, host: &mut dyn FnMut(StoryId) -> Arc) -> Vec<(ItemId, Placement)> { + let Some(st) = doc.story(sid) else { return Vec::new() }; + let mut anchored = None; + let mut out = Vec::new(); + for &fid in &st.frames { + if doc.find(fid).is_some() { + continue; + } + let items = anchored.get_or_insert_with(|| doc.anchored_items()); + // A frame anchored in its own story would need its own composition to place it. + let Some(owner) = items.iter().find_map(|(owner, it)| (it.id == fid).then_some(*owner)).filter(|o| *o != sid) else { continue }; + if let Some(p) = locate(doc, &host(owner), fid) { + out.push((fid, p)); + } + } + out +} + +/// Where `cs` (a host story's composition) places item `fid`: in its frames' anchored objects, +/// footnotes and table cells. +fn locate(doc: &Document, cs: &ComposedStory, fid: ItemId) -> Option { + cs.frames.iter().find_map(|ft| { + let loc = doc.find(ft.frame)?; + let frame = doc.item_at(&loc)?; + let xf = in_frame(ft, doc.parent_xf(&loc) * doc.text_xf(frame), fid, 0)?; + Some(Placement { spread: loc.spread, xf }) + }) +} + +/// The parent space of `fid` among the objects of `ft`, whose text space maps to the spread by +/// `xf`. +fn in_frame(ft: &FrameText, xf: Affine, fid: ItemId, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + let objects = ft.objects.iter().find_map(|o| in_item(&o.object.item, xf * Affine::translate(o.origin.to_vec2()), fid, 0)); + objects + .or_else(|| ft.notes.iter().find_map(|n| in_frame(n.text.frames.first()?, xf * Affine::translate(n.origin.to_vec2()), fid, depth + 1))) + .or_else(|| { + ft.tables + .iter() + .flat_map(|t| &t.cells) + .find_map(|c| in_frame(c.text.frames.first()?, xf * Affine::translate(c.origin.to_vec2()), fid, depth + 1)) + }) +} + +/// The parent space of `fid` in `it` (it or a group member), `it`'s parent space mapping to the +/// spread by `parent`. +fn in_item(it: &Item, parent: Affine, fid: ItemId, depth: usize) -> Option { + if it.id == fid { + return Some(parent); + } + if depth > crate::MAX_WRAP_DEPTH { + return None; + } + let xf = parent * it.xf; + it.children().iter().find_map(|c| in_item(c, xf, fid, depth + 1)) +} diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 0f739e16e..d972cad30 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -319,30 +319,50 @@ impl StyleTable<'_> { /// Resolve and shape one paragraph. Its drop cap ends at byte `drop.0` (`range.start` without /// one) and takes the character style `drop.1` ("" = none). -pub(crate) fn shape_para( - db: &ScopedFonts<'_>, - styles: &Styles, - story: &Story, - pi: usize, - range: std::ops::Range, - para_chars: &CharProps, - auto_leading: TypeEnv, - sub: &SubstCtx, - table: &mut StyleTable<'_>, - nested: &[designcraft_doc::NestedStyle], - grep: &[designcraft_doc::GrepStyle], - lines: &[(std::ops::Range, String)], - drop: (usize, &str), -) -> ShapedPara { - let _ = pi; - let mut glyphs = Vec::with_capacity(range.len()); - // Nested line styles lie under nested and GREP styles, and the drop cap's style over them all - // (later overlays win). +pub(crate) struct ShapeInput<'a, 'font> { + pub(crate) db: &'a ScopedFonts<'font>, + pub(crate) styles: &'a Styles, + pub(crate) story: &'a Story, + pub(crate) range: std::ops::Range, + pub(crate) para_chars: &'a CharProps, + pub(crate) auto_leading: TypeEnv, + pub(crate) sub: &'a SubstCtx, + pub(crate) nested: &'a [designcraft_doc::NestedStyle], + pub(crate) grep: &'a [designcraft_doc::GrepStyle], + pub(crate) lines: &'a [(std::ops::Range, String)], + pub(crate) drop_cap: (usize, &'a str), +} + +pub(crate) fn shape_para(input: ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> ShapedPara { + let db = input.db; + let sub = input.sub; + let range = input.range.clone(); + let auto_leading = input.auto_leading; + let (pieces, auto_tcy) = resolve_pieces(&input, table); + let mut glyphs = shape_pieces(&input, table, pieces); + fit_auto_tcy(&mut glyphs, &auto_tcy); + collapse_tcy(&mut glyphs, sub.vertical); + crate::ruby::reserve(db, table.styles, &mut glyphs, auto_leading.glyph_fallback, sub.vertical); + ShapedPara { glyphs, range } +} + +fn resolve_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>) -> (Vec, Vec>) { + let db = input.db; + let styles = input.styles; + let story = input.story; + let range = input.range.clone(); + let para_chars = input.para_chars; + let auto_leading = input.auto_leading; + let sub = input.sub; + let nested = input.nested; + let grep = input.grep; + let lines = input.lines; + let drop_cap = input.drop_cap; let mut overlays: Vec<(std::ops::Range, String)> = lines.to_vec(); // The drop cap's style is the nested styles' leading "through 1 Dropcap" entry (the form IDML // stores it in). Set (`[None]` included), it restyles that entry; a real one stands in for a // missing entry. Either way the nested styles after it start after the drop cap. - let set = Some(drop.1).filter(|s| !s.is_empty()); + let set = Some(drop_cap.1).filter(|s| !s.is_empty()); let cap_style = set.filter(|s| *s != designcraft_doc::NO_CHAR_STYLE); let with_cap: Vec; let nested = match set { @@ -357,12 +377,12 @@ pub(crate) fn shape_para( _ => nested, }; if !nested.is_empty() || !grep.is_empty() { - overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop.0)); + overlays.extend(crate::overlay::overlays(&story.text, range.clone(), nested, grep, drop_cap.0)); } if let Some(s) = cap_style - && drop.0 > range.start + && drop_cap.0 > range.start { - overlays.push((range.start..drop.0, s.to_string())); + overlays.push((range.start..drop_cap.0, s.to_string())); } // Each run, cut where nested / GREP styles start and end. let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new(); @@ -474,6 +494,15 @@ pub(crate) fn shape_para( pieces.push(Piece { range: k..b, props, style, env: auto_leading, hidden: false, composite_note: false }); } } + (pieces, auto_tcy) +} + +fn shape_pieces(input: &ShapeInput<'_, '_>, table: &mut StyleTable<'_>, pieces: Vec) -> Vec { + let db = input.db; + let styles = input.styles; + let story = input.story; + let sub = input.sub; + let mut glyphs = Vec::new(); let mut i = 0; while i < pieces.len() { let first = &pieces[i]; @@ -562,10 +591,7 @@ pub(crate) fn shape_para( } i = j; } - fit_auto_tcy(&mut glyphs, &auto_tcy); - collapse_tcy(&mut glyphs, sub.vertical); - crate::ruby::reserve(db, table.styles, &mut glyphs, auto_leading.glyph_fallback, sub.vertical); - ShapedPara { glyphs, range } + glyphs } /// Combining marks that start a run are shaped with the letter before them when the two runs diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 9a41a546e..c620622a3 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -1096,483 +1096,4 @@ mod height_tests { } #[cfg(test)] -mod border_tests { - use super::*; - use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument}; - - fn compose_table(table: Table) -> ComposedStory { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap(); - doc.story_mut(sid).unwrap().insert_table(0, table); - crate::compose_story(&doc, sid, &ComposeOptions::default()) - } - - #[test] - fn explicit_cell_edges_override_table_border_without_changing_native_defaults() { - let mut table = Table::new(77, 1, 1, 0, 0, 100.0); - table.options.border.weight = 2.0; - table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none()); - let native = compose_table(table.clone()); - let strokes = &native.frames[0].tables[0].strokes; - assert_eq!(strokes.len(), 4, "default cells still use all four table borders"); - assert!(strokes.iter().all(|s| s.stroke.weight == 2.0)); - - let cell = table.cell_mut(0, 0).unwrap(); - cell.border_overrides = [true, true, true, false]; - cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() }; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders"); - let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap(); - assert_eq!(bottom.stroke.weight, 3.0); - let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap(); - assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border"); - } - - #[test] - fn merged_cell_keeps_explicit_border_suppression() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - let cell = table.cell_mut(0, 0).unwrap(); - cell.border_overrides = [true; 4]; - cell.strokes = std::array::from_fn(|_| CellStroke::none()); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.cells.len(), 1); - assert!(fragment.strokes.is_empty()); - } - - fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table { - let mut table = Table::new(77, body, cols, header, footer, 100.0); - table.options.border = CellStroke::none(); - for row in &mut table.rows { - row.height = 20.0; - row.mode = RowHeightMode::Exactly; - } - for cell in &mut table.cells { - cell.insets = [0.0; 4]; - cell.strokes = std::array::from_fn(|_| CellStroke::none()); - } - table - } - - fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) { - let cell = table.cell_mut(row, col).unwrap(); - cell.strokes[side] = CellStroke { weight, ..Default::default() }; - cell.stroke_defined[side] = true; - cell.stroke_priorities[side] = priority; - } - - fn assert_no_overlapping_rules(fragment: &TableFrag) { - for (i, a) in fragment.strokes.iter().enumerate() { - for b in &fragment.strokes[i + 1..] { - let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x); - let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y); - assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}"); - } - } - } - - #[test] - fn header_bottom_only_style_survives_zero_body_edges() { - // Synthetic equivalent of a header with a one-point bottom rule and zero on all - // remaining header/body edges. Both sides supply style formatting at priority zero. - let mut table = rule_table(2, 3, 1, 0); - for cell in &mut table.cells { - cell.stroke_defined = [true; 4]; - cell.border_overrides = [true; 4]; - } - for col in 0..3 { - set_rule(&mut table, 0, col, 2, 1.0, 0); - } - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - let y = fragment.cell(0, 0).unwrap().rect.y1; - assert_eq!(fragment.strokes.len(), 3); - assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0)); - assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::(), 100.0); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 3.0, 7); - set_rule(&mut table, 1, 0, 0, 1.0, 4); - set_rule(&mut table, 0, 0, 3, 4.0, 3); - set_rule(&mut table, 0, 1, 1, 2.0, 9); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let horizontal = &fragment.strokes[0]; - assert_eq!(horizontal.a.y, horizontal.b.y); - assert_eq!(horizontal.stroke.weight, 3.0); - let vertical = &fragment.strokes[1]; - assert_eq!(vertical.a.x, vertical.b.x); - assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing"); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn signed_negative_priorities_resolve_and_draw_in_numeric_order() { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 1.0, -2); - set_rule(&mut table, 1, 0, 0, 3.0, -1); - set_rule(&mut table, 0, 0, 3, 2.0, -3); - set_rule(&mut table, 0, 1, 1, 4.0, -2); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1"); - assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x); - assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2"); - assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn default_zero_priority_beats_a_negative_priority_rule() { - let mut table = rule_table(2, 1, 0, 0); - set_rule(&mut table, 0, 0, 2, 3.0, -1); - assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]); - let composed = compose_table(table.clone()); - assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1"); - table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default(); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 1); - assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1"); - } - - #[test] - fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() { - for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 3.0, 7); - set_rule(&mut table, 1, 0, 0, 0.0, 8); - table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone(); - set_rule(&mut table, 0, 0, 3, 3.0, 7); - set_rule(&mut table, 0, 1, 1, 0.0, 8); - table.cell_mut(0, 1).unwrap().strokes[1] = invisible; - let composed = compose_table(table); - assert!(composed.frames[0].tables[0].strokes.is_empty()); - } - } - - #[test] - fn supplied_zero_shared_edge_beats_missing_native_default() { - let mut table = Table::new(77, 2, 1, 0, 0, 100.0); - table.options.border = CellStroke::none(); - let upper = table.cell_mut(0, 0).unwrap(); - upper.strokes[2] = CellStroke::none(); - upper.stroke_defined[2] = true; - let composed = compose_table(table.clone()); - assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default"); - table.cell_mut(0, 0).unwrap().stroke_defined[2] = false; - let composed = compose_table(table); - assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default"); - } - - #[test] - fn specified_zero_priority_outer_edges_keep_per_side_table_borders() { - for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] { - let mut table = rule_table(1, 1, 0, 0); - table.options.direction = direction; - table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() })); - table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4]; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 4); - for rule in &fragment.strokes { - let expected = if rule.a.y == rule.b.y { - if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 } - } else if rule.a.x == fragment.rect.x0 { - 2.0 - } else { - 4.0 - }; - assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting"); - } - assert_no_overlapping_rules(fragment); - } - } - - fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes { - let stroke = CellStroke { weight, ..Default::default() }; - designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 } - } - - #[test] - fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.row_strokes = Some(uniform_pattern(0.0)); - table.options.column_strokes = Some(uniform_pattern(0.0)); - table.options.border.weight = 2.0; - let composed = compose_table(table.clone()); - let fragment = &composed.frames[0].tables[0]; - assert!(!fragment.strokes.is_empty(), "table perimeter remains visible"); - assert!(fragment.strokes.iter().all(|s| { - s.stroke.weight == 2.0 - && ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1)) - || (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1))) - })); - assert_no_overlapping_rules(fragment); - table.options.border = CellStroke::none(); - set_rule(&mut table, 0, 0, 2, 3.0, 0); - set_rule(&mut table, 0, 1, 1, 4.0, 0); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns"); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0)); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0)); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.border = CellStroke::none(); - table.options.row_strokes = Some(uniform_pattern(2.0)); - table.options.column_strokes = Some(uniform_pattern(3.0)); - let composed = compose_table(table.clone()); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries"); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0)); - assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height())); - for cell in &mut table.cells { - cell.strokes = std::array::from_fn(|_| CellStroke::none()); - cell.stroke_defined = [true; 4]; - } - let composed = compose_table(table); - assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns"); - } - - #[test] - fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() { - let mut table = Table::new(77, 2, 2, 1, 1, 100.0); - table.options.border = CellStroke::none(); - table.options.row_strokes = Some(uniform_pattern(0.0)); - table.options.column_strokes = Some(uniform_pattern(0.0)); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - let header = fragment.cell(0, 0).unwrap().rect; - let body = fragment.cell(1, 0).unwrap().rect; - let footer = fragment.cell(3, 0).unwrap().rect; - assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1))); - assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1))); - assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1)); - assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1))); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() { - for skipped in [false, true] { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.border = CellStroke::none(); - let mut pattern = uniform_pattern(2.0); - if skipped { - pattern.skip_first = 1; - } else { - pattern.next_stroke.weight = 3.0; - } - table.options.row_strokes = Some(pattern.clone()); - table.options.column_strokes = Some(pattern); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 4); - assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0)); - } - } - - #[test] - fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() { - let mut table = Table::new(77, 2, 2, 0, 0, 100.0); - table.options.border = CellStroke::none(); - let composed = compose_table(table.clone()); - assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control"); - // The metadata arrays also default to zero/false when reading legacy JSON. Older - // native table.setCell code changed these strokes without setting any metadata. - table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none(); - table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into(); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let hidden_top = fragment.cell(1, 0).unwrap().rect; - let hidden_left = fragment.cell(0, 1).unwrap().rect; - assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0))); - assert!( - !fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1)) - ); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn equal_priority_visible_rules_keep_top_and_left_ownership() { - let mut table = rule_table(2, 2, 0, 0); - set_rule(&mut table, 0, 0, 2, 5.0, 0); - set_rule(&mut table, 1, 0, 0, 2.0, 0); - set_rule(&mut table, 0, 0, 3, 6.0, 0); - set_rule(&mut table, 0, 1, 1, 3.0, 0); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - assert_eq!(fragment.strokes[0].stroke.weight, 2.0); - assert_eq!(fragment.strokes[1].stroke.weight, 3.0); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() { - let mut table = rule_table(2, 2, 0, 0); - table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); - set_rule(&mut table, 0, 0, 3, 1.0, 5); - set_rule(&mut table, 0, 1, 1, 2.0, 6); - set_rule(&mut table, 1, 1, 1, 3.0, 4); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); - let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); - assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0)); - assert_eq!(upper.b, lower.a); - assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1)); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() { - let mut table = rule_table(2, 2, 0, 0); - table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); - set_rule(&mut table, 0, 0, 2, 1.0, 5); - set_rule(&mut table, 1, 0, 0, 2.0, 6); - set_rule(&mut table, 1, 1, 0, 3.0, 4); - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 2); - let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); - let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); - let y = fragment.cell(0, 0).unwrap().rect.y1; - assert_eq!(left.a, Point::new(fragment.rect.x0, y)); - assert_eq!(left.b, right.a); - assert_eq!(right.b, Point::new(fragment.rect.x1, y)); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() { - let mut table = rule_table(2, 2, 0, 0); - table.columns.iter_mut().for_each(|column| column.width = 45.0); - table.merge(CellRange::new(1, 0, 1, 1)).unwrap(); - set_rule(&mut table, 1, 0, 0, 1.0, 1); - table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] }; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase"); - let rule = &fragment.strokes[0]; - let merged = fragment.cell(1, 0).unwrap().rect; - assert_eq!(rule.a, Point::new(merged.x0, merged.y0)); - assert_eq!(rule.b, Point::new(merged.x1, merged.y0)); - assert_eq!(rule.b.x - rule.a.x, 90.0); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn merged_perimeter_keeps_four_continuous_strokes() { - let mut table = rule_table(2, 2, 0, 0); - table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - table.options.border.weight = 2.0; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 4); - assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2); - assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2); - assert_no_overlapping_rules(fragment); - } - - #[test] - fn rtl_shared_rules_use_physical_edges_and_outer_borders() { - let mut table = rule_table(1, 2, 0, 0); - table.options.direction = designcraft_doc::TextDirection::RightToLeft; - table.columns[0].width = 30.0; - table.columns[1].width = 70.0; - set_rule(&mut table, 0, 0, 1, 2.0, 9); - set_rule(&mut table, 0, 1, 3, 4.0, 2); - set_rule(&mut table, 0, 0, 3, 5.0, 3); - table.cell_mut(0, 0).unwrap().border_overrides[3] = true; - set_rule(&mut table, 0, 1, 1, 6.0, 4); - table.cell_mut(0, 1).unwrap().border_overrides[1] = true; - let composed = compose_table(table); - let fragment = &composed.frames[0].tables[0]; - assert_eq!(fragment.strokes.len(), 3); - for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] { - let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap(); - assert_eq!(rule.stroke.weight, weight); - } - assert_no_overlapping_rules(fragment); - } - - fn compose_two_fragments(table: Table, height: f64) -> ComposedStory { - let mut doc = Document::new(&NewDocument::default()); - let layer = doc.default_layer(); - let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap(); - let (second, _) = - doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap(); - doc.thread(first, second).unwrap(); - doc.story_mut(sid).unwrap().insert_table(0, table); - let composed = crate::compose_story(&doc, sid, &ComposeOptions::default()); - assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at); - assert_eq!(composed.frames[0].tables.len(), 1); - assert_eq!(composed.frames[1].tables.len(), 1); - composed - } - - #[test] - fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() { - let mut table = rule_table(4, 2, 1, 1); - table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); - table.merge(CellRange::new(5, 0, 5, 1)).unwrap(); - set_rule(&mut table, 0, 0, 2, 1.0, 2); - set_rule(&mut table, 5, 0, 0, 2.0, 3); - // Keep 19 points for the anchor's trailing empty paragraph, still below the - // 100 points needed to fit a third body row plus the header and footer. - let composed = compose_two_fragments(table, 99.0); - for (i, frame) in composed.frames.iter().enumerate() { - let fragment = &frame.tables[0]; - let header = fragment.cell(0, 0).unwrap(); - let footer = fragment.cell(5, 0).unwrap(); - assert_eq!(header.repeated, i == 1); - assert_eq!(footer.repeated, i == 0); - assert_eq!(fragment.strokes.len(), 2); - let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect(); - let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect(); - assert_eq!(header_rules.len(), 1); - assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0)); - assert_eq!(footer_rules.len(), 1); - assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0)); - assert_no_overlapping_rules(fragment); - } - assert!(composed.frames[0].tables[0].cell(1, 0).is_some()); - assert!(composed.frames[1].tables[0].cell(3, 0).is_some()); - } - - #[test] - fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() { - let mut table = rule_table(4, 1, 0, 0); - set_rule(&mut table, 1, 0, 2, 3.0, 5); - table.cell_mut(1, 0).unwrap().border_overrides[2] = true; - set_rule(&mut table, 2, 0, 0, 4.0, 6); - table.cell_mut(2, 0).unwrap().border_overrides[0] = true; - // Two rows fit in each frame; leave room for the trailing empty paragraph. - let composed = compose_two_fragments(table, 59.0); - let first = &composed.frames[0].tables[0]; - let second = &composed.frames[1].tables[0]; - assert_eq!(first.strokes.len(), 1); - assert_eq!(first.strokes[0].stroke.weight, 3.0); - assert_eq!(first.strokes[0].a.y, first.rect.y1); - assert_eq!(second.strokes.len(), 1); - assert_eq!(second.strokes[0].stroke.weight, 4.0); - assert_eq!(second.strokes[0].a.y, second.rect.y0); - } -} +mod border_tests; diff --git a/crates/compose/src/table/border_tests.rs b/crates/compose/src/table/border_tests.rs new file mode 100644 index 000000000..5a28d7274 --- /dev/null +++ b/crates/compose/src/table/border_tests.rs @@ -0,0 +1,475 @@ +use super::*; +use designcraft_doc::{CellRange, ParaFormat, SpreadRef, build::NewDocument}; + +fn compose_table(table: Table) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, 200.0), layer, "", ParaFormat::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + crate::compose_story(&doc, sid, &ComposeOptions::default()) +} + +#[test] +fn explicit_cell_edges_override_table_border_without_changing_native_defaults() { + let mut table = Table::new(77, 1, 1, 0, 0, 100.0); + table.options.border.weight = 2.0; + table.cell_mut(0, 0).unwrap().strokes = std::array::from_fn(|_| CellStroke::none()); + let native = compose_table(table.clone()); + let strokes = &native.frames[0].tables[0].strokes; + assert_eq!(strokes.len(), 4, "default cells still use all four table borders"); + assert!(strokes.iter().all(|s| s.stroke.weight == 2.0)); + + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true, true, true, false]; + cell.strokes[2] = CellStroke { weight: 3.0, ..Default::default() }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "top and left None overrides suppress borders"); + let bottom = fragment.strokes.iter().find(|s| s.a.y == fragment.rect.y1 && s.b.y == fragment.rect.y1).unwrap(); + assert_eq!(bottom.stroke.weight, 3.0); + let right = fragment.strokes.iter().find(|s| s.a.x == fragment.rect.x1 && s.b.x == fragment.rect.x1).unwrap(); + assert_eq!(right.stroke.weight, 2.0, "unoverridden right edge keeps the table border"); +} + +#[test] +fn merged_cell_keeps_explicit_border_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let cell = table.cell_mut(0, 0).unwrap(); + cell.border_overrides = [true; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.cells.len(), 1); + assert!(fragment.strokes.is_empty()); +} + +fn rule_table(body: usize, cols: usize, header: usize, footer: usize) -> Table { + let mut table = Table::new(77, body, cols, header, footer, 100.0); + table.options.border = CellStroke::none(); + for row in &mut table.rows { + row.height = 20.0; + row.mode = RowHeightMode::Exactly; + } + for cell in &mut table.cells { + cell.insets = [0.0; 4]; + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + } + table +} + +fn set_rule(table: &mut Table, row: usize, col: usize, side: usize, weight: f64, priority: i32) { + let cell = table.cell_mut(row, col).unwrap(); + cell.strokes[side] = CellStroke { weight, ..Default::default() }; + cell.stroke_defined[side] = true; + cell.stroke_priorities[side] = priority; +} + +fn assert_no_overlapping_rules(fragment: &TableFrag) { + for (i, a) in fragment.strokes.iter().enumerate() { + for b in &fragment.strokes[i + 1..] { + let horizontal_overlap = a.a.y == a.b.y && a.a.y == b.a.y && b.a.y == b.b.y && a.a.x.max(b.a.x) < a.b.x.min(b.b.x); + let vertical_overlap = a.a.x == a.b.x && a.a.x == b.a.x && b.a.x == b.b.x && a.a.y.max(b.a.y) < a.b.y.min(b.b.y); + assert!(!horizontal_overlap && !vertical_overlap, "duplicate shared edge: {a:?}, {b:?}"); + } + } +} + +#[test] +fn header_bottom_only_style_survives_zero_body_edges() { + // Synthetic equivalent of a header with a one-point bottom rule and zero on all + // remaining header/body edges. Both sides supply style formatting at priority zero. + let mut table = rule_table(2, 3, 1, 0); + for cell in &mut table.cells { + cell.stroke_defined = [true; 4]; + cell.border_overrides = [true; 4]; + } + for col in 0..3 { + set_rule(&mut table, 0, col, 2, 1.0, 0); + } + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(fragment.strokes.len(), 3); + assert!(fragment.strokes.iter().all(|s| s.a.y == y && s.b.y == y && s.stroke.weight == 1.0)); + assert_eq!(fragment.strokes.iter().map(|s| s.b.x - s.a.x).sum::(), 100.0); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn shared_rules_choose_numeric_priority_once_and_preserve_drawing_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 1.0, 4); + set_rule(&mut table, 0, 0, 3, 4.0, 3); + set_rule(&mut table, 0, 1, 1, 2.0, 9); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let horizontal = &fragment.strokes[0]; + assert_eq!(horizontal.a.y, horizontal.b.y); + assert_eq!(horizontal.stroke.weight, 3.0); + let vertical = &fragment.strokes[1]; + assert_eq!(vertical.a.x, vertical.b.x); + assert_eq!(vertical.stroke.weight, 2.0, "higher priority draws last at a crossing"); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn signed_negative_priorities_resolve_and_draw_in_numeric_order() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 1.0, -2); + set_rule(&mut table, 1, 0, 0, 3.0, -1); + set_rule(&mut table, 0, 0, 3, 2.0, -3); + set_rule(&mut table, 0, 1, 1, 4.0, -2); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 4.0, "priority -2 draws before -1"); + assert_eq!(fragment.strokes[0].a.x, fragment.strokes[0].b.x); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0, "priority -1 wins over -2"); + assert_eq!(fragment.strokes[1].a.y, fragment.strokes[1].b.y); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn default_zero_priority_beats_a_negative_priority_rule() { + let mut table = rule_table(2, 1, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, -1); + assert_eq!(table.cell(1, 0).unwrap().stroke_priorities, [0; 4]); + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "priority zero None suppresses priority -1"); + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::default(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1); + assert_eq!(fragment.strokes[0].stroke.weight, 1.0, "the priority-zero native rule wins over priority -1"); +} + +#[test] +fn higher_priority_zero_or_none_suppresses_a_visible_shared_rule() { + for invisible in [CellStroke::none(), CellStroke { color: designcraft_color::swatch::NONE.into(), ..Default::default() }] { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 3.0, 7); + set_rule(&mut table, 1, 0, 0, 0.0, 8); + table.cell_mut(1, 0).unwrap().strokes[0] = invisible.clone(); + set_rule(&mut table, 0, 0, 3, 3.0, 7); + set_rule(&mut table, 0, 1, 1, 0.0, 8); + table.cell_mut(0, 1).unwrap().strokes[1] = invisible; + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty()); + } +} + +#[test] +fn supplied_zero_shared_edge_beats_missing_native_default() { + let mut table = Table::new(77, 2, 1, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let upper = table.cell_mut(0, 0).unwrap(); + upper.strokes[2] = CellStroke::none(); + upper.stroke_defined[2] = true; + let composed = compose_table(table.clone()); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit zero beats a missing edge's one-point default"); + table.cell_mut(0, 0).unwrap().stroke_defined[2] = false; + let composed = compose_table(table); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 1, "a missing zero cannot suppress the opposite visible default"); +} + +#[test] +fn specified_zero_priority_outer_edges_keep_per_side_table_borders() { + for direction in [designcraft_doc::TextDirection::LeftToRight, designcraft_doc::TextDirection::RightToLeft] { + let mut table = rule_table(1, 1, 0, 0); + table.options.direction = direction; + table.options.borders = std::array::from_fn(|i| Some(CellStroke { weight: (i + 1) as f64, ..Default::default() })); + table.cell_mut(0, 0).unwrap().stroke_defined = [true; 4]; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + for rule in &fragment.strokes { + let expected = if rule.a.y == rule.b.y { + if rule.a.y == fragment.rect.y0 { 1.0 } else { 3.0 } + } else if rule.a.x == fragment.rect.x0 { + 2.0 + } else { + 4.0 + }; + assert_eq!(rule.stroke.weight, expected, "priority-zero cell values do not override perimeter formatting"); + } + assert_no_overlapping_rules(fragment); + } +} + +fn uniform_pattern(weight: f64) -> designcraft_doc::AltStrokes { + let stroke = CellStroke { weight, ..Default::default() }; + designcraft_doc::AltStrokes { first: 1, next: 1, first_stroke: stroke.clone(), next_stroke: stroke, skip_first: 0, skip_last: 0 } +} + +#[test] +fn uniform_zero_table_patterns_suppress_missing_grid_edges_but_not_perimeter() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + table.options.border.weight = 2.0; + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert!(!fragment.strokes.is_empty(), "table perimeter remains visible"); + assert!(fragment.strokes.iter().all(|s| { + s.stroke.weight == 2.0 + && ((s.a.x == s.b.x && (s.a.x == fragment.rect.x0 || s.a.x == fragment.rect.x1)) + || (s.a.y == s.b.y && (s.a.y == fragment.rect.y0 || s.a.y == fragment.rect.y1))) + })); + assert_no_overlapping_rules(fragment); + table.options.border = CellStroke::none(); + set_rule(&mut table, 0, 0, 2, 3.0, 0); + set_rule(&mut table, 0, 1, 1, 4.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "supplied cell strokes override the table patterns"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 4.0)); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn uniform_visible_table_patterns_supply_continuous_rules_below_explicit_none() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(2.0)); + table.options.column_strokes = Some(uniform_pattern(3.0)); + let composed = compose_table(table.clone()); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2, "a table pattern stays continuous across cell boundaries"); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 2.0 && s.b.x - s.a.x == 100.0)); + assert!(fragment.strokes.iter().any(|s| s.stroke.weight == 3.0 && s.b.y - s.a.y == fragment.rect.height())); + for cell in &mut table.cells { + cell.strokes = std::array::from_fn(|_| CellStroke::none()); + cell.stroke_defined = [true; 4]; + } + let composed = compose_table(table); + assert!(composed.frames[0].tables[0].strokes.is_empty(), "explicit cell None is stronger than table patterns"); +} + +#[test] +fn uniform_table_patterns_leave_header_and_footer_edges_unchanged() { + let mut table = Table::new(77, 2, 2, 1, 1, 100.0); + table.options.border = CellStroke::none(); + table.options.row_strokes = Some(uniform_pattern(0.0)); + table.options.column_strokes = Some(uniform_pattern(0.0)); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + let header = fragment.cell(0, 0).unwrap().rect; + let body = fragment.cell(1, 0).unwrap().rect; + let footer = fragment.cell(3, 0).unwrap().rect; + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(header.x1, header.y0) && s.b == Point::new(header.x1, header.y1))); + assert!(fragment.strokes.iter().any(|s| s.a == Point::new(footer.x1, footer.y0) && s.b == Point::new(footer.x1, footer.y1))); + assert!(!fragment.strokes.iter().any(|s| s.a.y == body.y1 && s.b.y == body.y1)); + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(body.x1, body.y0) && s.b == Point::new(body.x1, body.y1))); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn unverified_nonuniform_or_skipped_pattern_phase_keeps_native_edges() { + for skipped in [false, true] { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let mut pattern = uniform_pattern(2.0); + if skipped { + pattern.skip_first = 1; + } else { + pattern.next_stroke.weight = 3.0; + } + table.options.row_strokes = Some(pattern.clone()); + table.options.column_strokes = Some(pattern); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert!(fragment.strokes.iter().all(|s| s.stroke.weight == 1.0)); + } +} + +#[test] +fn metadata_free_native_and_legacy_edges_keep_top_and_left_suppression() { + let mut table = Table::new(77, 2, 2, 0, 0, 100.0); + table.options.border = CellStroke::none(); + let composed = compose_table(table.clone()); + assert_eq!(composed.frames[0].tables[0].strokes.len(), 4, "unmodified native grid control"); + // The metadata arrays also default to zero/false when reading legacy JSON. Older + // native table.setCell code changed these strokes without setting any metadata. + table.cell_mut(1, 0).unwrap().strokes[0] = CellStroke::none(); + table.cell_mut(0, 1).unwrap().strokes[1].color = designcraft_color::swatch::NONE.into(); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let hidden_top = fragment.cell(1, 0).unwrap().rect; + let hidden_left = fragment.cell(0, 1).unwrap().rect; + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_top.x0, hidden_top.y0) && s.b == Point::new(hidden_top.x1, hidden_top.y0))); + assert!(!fragment.strokes.iter().any(|s| s.a == Point::new(hidden_left.x0, hidden_left.y0) && s.b == Point::new(hidden_left.x0, hidden_left.y1))); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn equal_priority_visible_rules_keep_top_and_left_ownership() { + let mut table = rule_table(2, 2, 0, 0); + set_rule(&mut table, 0, 0, 2, 5.0, 0); + set_rule(&mut table, 1, 0, 0, 2.0, 0); + set_rule(&mut table, 0, 0, 3, 6.0, 0); + set_rule(&mut table, 0, 1, 1, 3.0, 0); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + assert_eq!(fragment.strokes[0].stroke.weight, 2.0); + assert_eq!(fragment.strokes[1].stroke.weight, 3.0); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn row_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 0)).unwrap(); + set_rule(&mut table, 0, 0, 3, 1.0, 5); + set_rule(&mut table, 0, 1, 1, 2.0, 6); + set_rule(&mut table, 1, 1, 1, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let upper = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let lower = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + assert_eq!(upper.a, Point::new(50.0, fragment.rect.y0)); + assert_eq!(upper.b, lower.a); + assert_eq!(lower.b, Point::new(50.0, fragment.rect.y1)); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn column_spanning_edge_resolves_each_neighbor_without_internal_rules() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 5); + set_rule(&mut table, 1, 0, 0, 2.0, 6); + set_rule(&mut table, 1, 1, 0, 3.0, 4); + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 2); + let left = fragment.strokes.iter().find(|s| s.stroke.weight == 2.0).unwrap(); + let right = fragment.strokes.iter().find(|s| s.stroke.weight == 1.0).unwrap(); + let y = fragment.cell(0, 0).unwrap().rect.y1; + assert_eq!(left.a, Point::new(fragment.rect.x0, y)); + assert_eq!(left.b, right.a); + assert_eq!(right.b, Point::new(fragment.rect.x1, y)); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn merged_winning_dashed_edge_stays_continuous_across_losing_neighbors() { + let mut table = rule_table(2, 2, 0, 0); + table.columns.iter_mut().for_each(|column| column.width = 45.0); + table.merge(CellRange::new(1, 0, 1, 1)).unwrap(); + set_rule(&mut table, 1, 0, 0, 1.0, 1); + table.cell_mut(1, 0).unwrap().strokes[0].kind = designcraft_doc::StrokeType::Dashed { pattern: vec![6.0, 4.0] }; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 1, "one winning source edge must retain one dash phase"); + let rule = &fragment.strokes[0]; + let merged = fragment.cell(1, 0).unwrap().rect; + assert_eq!(rule.a, Point::new(merged.x0, merged.y0)); + assert_eq!(rule.b, Point::new(merged.x1, merged.y0)); + assert_eq!(rule.b.x - rule.a.x, 90.0); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn merged_perimeter_keeps_four_continuous_strokes() { + let mut table = rule_table(2, 2, 0, 0); + table.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + table.options.border.weight = 2.0; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 4); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.y == s.b.y).count(), 2); + assert_eq!(fragment.strokes.iter().filter(|s| s.a.x == s.b.x).count(), 2); + assert_no_overlapping_rules(fragment); +} + +#[test] +fn rtl_shared_rules_use_physical_edges_and_outer_borders() { + let mut table = rule_table(1, 2, 0, 0); + table.options.direction = designcraft_doc::TextDirection::RightToLeft; + table.columns[0].width = 30.0; + table.columns[1].width = 70.0; + set_rule(&mut table, 0, 0, 1, 2.0, 9); + set_rule(&mut table, 0, 1, 3, 4.0, 2); + set_rule(&mut table, 0, 0, 3, 5.0, 3); + table.cell_mut(0, 0).unwrap().border_overrides[3] = true; + set_rule(&mut table, 0, 1, 1, 6.0, 4); + table.cell_mut(0, 1).unwrap().border_overrides[1] = true; + let composed = compose_table(table); + let fragment = &composed.frames[0].tables[0]; + assert_eq!(fragment.strokes.len(), 3); + for (x, weight) in [(0.0, 6.0), (70.0, 2.0), (100.0, 5.0)] { + let rule = fragment.strokes.iter().find(|s| s.a.x == x && s.b.x == x).unwrap(); + assert_eq!(rule.stroke.weight, weight); + } + assert_no_overlapping_rules(fragment); +} + +fn compose_two_fragments(table: Table, height: f64) -> ComposedStory { + let mut doc = Document::new(&NewDocument::default()); + let layer = doc.default_layer(); + let (first, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 120.0, height), layer, "", ParaFormat::default()).unwrap(); + let (second, _) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 200.0, 120.0, 200.0 + height), layer, "", ParaFormat::default()).unwrap(); + doc.thread(first, second).unwrap(); + doc.story_mut(sid).unwrap().insert_table(0, table); + let composed = crate::compose_story(&doc, sid, &ComposeOptions::default()); + assert!(!composed.is_overset(), "overset at {:?}", composed.overset_at); + assert_eq!(composed.frames[0].tables.len(), 1); + assert_eq!(composed.frames[1].tables.len(), 1); + composed +} + +#[test] +fn repeated_header_and_footer_rules_resolve_displayed_fragment_neighbors() { + let mut table = rule_table(4, 2, 1, 1); + table.merge(CellRange::new(0, 0, 0, 1)).unwrap(); + table.merge(CellRange::new(5, 0, 5, 1)).unwrap(); + set_rule(&mut table, 0, 0, 2, 1.0, 2); + set_rule(&mut table, 5, 0, 0, 2.0, 3); + // Keep 19 points for the anchor's trailing empty paragraph, still below the + // 100 points needed to fit a third body row plus the header and footer. + let composed = compose_two_fragments(table, 99.0); + for (i, frame) in composed.frames.iter().enumerate() { + let fragment = &frame.tables[0]; + let header = fragment.cell(0, 0).unwrap(); + let footer = fragment.cell(5, 0).unwrap(); + assert_eq!(header.repeated, i == 1); + assert_eq!(footer.repeated, i == 0); + assert_eq!(fragment.strokes.len(), 2); + let header_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == header.rect.y1 && s.b.y == header.rect.y1).collect(); + let footer_rules: Vec<_> = fragment.strokes.iter().filter(|s| s.a.y == footer.rect.y0 && s.b.y == footer.rect.y0).collect(); + assert_eq!(header_rules.len(), 1); + assert!(header_rules.iter().all(|s| s.stroke.weight == 1.0)); + assert_eq!(footer_rules.len(), 1); + assert!(footer_rules.iter().all(|s| s.stroke.weight == 2.0)); + assert_no_overlapping_rules(fragment); + } + assert!(composed.frames[0].tables[0].cell(1, 0).is_some()); + assert!(composed.frames[1].tables[0].cell(3, 0).is_some()); +} + +#[test] +fn fragment_boundary_does_not_resolve_against_an_undisplayed_neighbor() { + let mut table = rule_table(4, 1, 0, 0); + set_rule(&mut table, 1, 0, 2, 3.0, 5); + table.cell_mut(1, 0).unwrap().border_overrides[2] = true; + set_rule(&mut table, 2, 0, 0, 4.0, 6); + table.cell_mut(2, 0).unwrap().border_overrides[0] = true; + // Two rows fit in each frame; leave room for the trailing empty paragraph. + let composed = compose_two_fragments(table, 59.0); + let first = &composed.frames[0].tables[0]; + let second = &composed.frames[1].tables[0]; + assert_eq!(first.strokes.len(), 1); + assert_eq!(first.strokes[0].stroke.weight, 3.0); + assert_eq!(first.strokes[0].a.y, first.rect.y1); + assert_eq!(second.strokes.len(), 1); + assert_eq!(second.strokes[0].stroke.weight, 4.0); + assert_eq!(second.strokes[0].a.y, second.rect.y0); +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6e1784f5e..3083261fb 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() { ); let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true)); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), @@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() { let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame); // The image overhangs the frame on every side. it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(d.alloc()), size: (300.0, 250.0), xf: designcraft_geom::Affine::IDENTITY, @@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() { true, )); it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, asset: designcraft_doc::AssetId(1), size: (150.0, 200.0), xf: designcraft_geom::Affine::translate((150.0, 100.0)), @@ -1358,6 +1361,111 @@ fn optical_margin_hangs_punctuation() { assert!(lines.iter().all(|l| l.end_x <= l.x1 + 12.0)); } +#[test] +fn optical_margin_hangs_the_edges_of_lines_with_tabs() { + // Both line breakers let the edge glyphs of a line hang, tab or not, so layout hangs them + // too: a line that fits only because its period hangs is not squeezed into the measure or + // set past it. The left hang leaves tab stops where they are. + let stop = designcraft_doc::TabStop { position: 40.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + let rest = "right here. More words follow it on the next line."; + let cases = [ + ("tab", format!("“A”\tsits {rest}"), Align::LeftJustified), + ("no tab", format!("“A” sits {rest}"), Align::LeftJustified), + ("right indent tab", format!("“A” sits{}{rest}", story::RIGHT_INDENT_TAB), Align::Left), + ]; + for (name, text, align) in &cases { + let ch = |g: &PlacedGlyph| text.get(g.byte..).and_then(|s| s.chars().next()).unwrap_or(' '); + let edge_glyphs = |l: &Line| -> Vec { l.glyphs.iter().filter(|g| g.visible && !ch(g).is_whitespace()).cloned().collect() }; + for composer in [Composer::Paragraph, Composer::SingleLine] { + let mut ending = 0; + for w in 100..=240 { + let para = ParaAttrs { + align: Some(*align), + optical_margin: Some(true), + hyphenate: Some(false), + composer: Some(composer), + tabs: Some(vec![stop.clone()]), + ..Default::default() + }; + let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, f64::from(w), 1000.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + for (i, l) in lines.iter().enumerate() { + let Some(last) = edge_glyphs(l).last().cloned() else { continue }; + let hang = breaker::hang(ch(&last)).1 * last.adv; + let end = last.x + last.adv; + assert!(end <= l.x1 + hang + 0.01, "{name}, {composer:?}, width {w}: line {i} ends at {end}, past {} and its hang {hang}", l.x1); + } + let glyphs = edge_glyphs(lines[0]); + let (x0, x1) = (lines[0].x0, lines[0].x1); + let first = &glyphs[0]; + assert!((first.x + first.adv - x0).abs() < 0.01, "{name}, {composer:?}, width {w}: the opening quote is at {}", first.x); + if *name == "tab" { + let s = glyphs.iter().find(|g| ch(g) == 's').unwrap(); + assert!((s.x - (x0 + 40.0)).abs() < 0.01, "{composer:?}, width {w}: the text after the tab starts at {}", s.x); + } + // Line 0 ends with the period of "here.": the period hangs past the measure. + let last = glyphs.last().unwrap(); + if text.get(last.byte..).is_some_and(|s| s.starts_with(". More")) { + ending += 1; + assert!((last.x - x1).abs() < 0.01, "{name}, {composer:?}, width {w}: the period starts at {}, not at {x1}", last.x); + } + } + assert!(ending > 3, "{name}, {composer:?}: line 0 ends with the period at {ending} widths"); + } + } +} + +/// How far the opening quote of a one-line paragraph at `text_size` hangs into the left margin, +/// with the story's Optical Margin Alignment `story` (on, size) and the paragraph's attributes. +fn quote_hang(text_size: f64, story: Option, para: ParaAttrs) -> f64 { + let text = "“Quoted” words."; + let first_x = |story: Option, para: ParaAttrs| { + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 400.0, 200.0), para); + let st = d.story_mut(sid).unwrap(); + st.format_chars(0..text.len(), |f| f.over.size = Some(text_size)); + if let Some(size) = story { + st.optical_margin = designcraft_doc::OpticalMargin { on: true, size }; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 1); + lines[0].glyphs[0].x + }; + first_x(None, ParaAttrs::default()) - first_x(story, para) +} + +#[test] +fn story_optical_margin_hang_scales_with_its_size() { + let at12 = quote_hang(12.0, Some(12.0), ParaAttrs::default()); + let at24 = quote_hang(12.0, Some(24.0), ParaAttrs::default()); + assert!(at12 > 1.0, "{at12}"); + assert!((at24 - 2.0 * at12).abs() < 0.01, "{at12} {at24}"); + // The size is the type size the hang is set for: 24 pt text at 24 hangs like 12 at 12, twice as far. + let big = quote_hang(24.0, Some(24.0), ParaAttrs::default()); + assert!((big - 2.0 * at12).abs() < 0.01, "{at12} {big}"); +} + +#[test] +fn ignore_optical_margin_paragraph_does_not_hang() { + let ignored = ParaAttrs { ignore_optical_margin: Some(true), optical_margin: Some(true), ..Default::default() }; + assert_eq!(quote_hang(12.0, Some(12.0), ignored.clone()), 0.0); + assert_eq!(quote_hang(12.0, None, ignored), 0.0); +} + +#[test] +fn paragraph_optical_margin_hangs_at_the_text_size() { + // Documents saved before the story setting existed: the paragraph's switch hangs each + // character by its own size, not the story's default 12 pt. + let own = ParaAttrs { optical_margin: Some(true), ..Default::default() }; + let at24 = quote_hang(24.0, None, own.clone()); + assert!((at24 - quote_hang(24.0, Some(24.0), ParaAttrs::default())).abs() < 0.01); + assert!((at24 - 2.0 * quote_hang(12.0, None, own)).abs() < 0.01); + // The story setting, when on, sets the size. + let story = quote_hang(24.0, Some(12.0), ParaAttrs { optical_margin: Some(true), ..Default::default() }); + assert!((story - at24 / 2.0).abs() < 0.01, "{story} {at24}"); +} + // ---------- golden paragraph corpus ---------- /// Original prose (written for this corpus) with a realistic mix of short and long words. @@ -5595,14 +5703,27 @@ fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, (d, fid, rules) } +/// Returns the top of the first frame's top line (its ascent) and the bottom of its bottom line +/// (its descent). +fn text_extent(d: &Document, fid: ItemId) -> (f64, f64) { + let sid = d.item(fid).unwrap().text_frame().unwrap().story; + let cs = compose_story(d, sid, &ComposeOptions::default()); + let lines = &cs.frames[0].lines; + assert!(!lines.is_empty()); + let top = lines.iter().map(|l| l.baseline - l.ascent).fold(f64::INFINITY, f64::min); + let bottom = lines.iter().map(|l| l.baseline + l.descent).fold(f64::NEG_INFINITY, f64::max); + (top, bottom) +} + #[test] fn column_rules_sit_in_the_gutters_inside_the_insets() { // Two columns in 0..300 with 6 pt side insets: the gutter is centred on 150. - let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {}); + let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |_, _| {}); assert_eq!(rules.len(), 1, "{rules:?}"); let r = rules[0]; assert!((r.center().x - 150.0).abs() < 1e-9 && (r.width() - 2.0).abs() < 1e-9, "{r:?}"); - assert!((r.y0 - 10.0).abs() < 1e-9 && (r.y1 - 180.0).abs() < 1e-9, "{r:?}"); + let (top, bottom) = text_extent(&d, fid); + assert!((r.y0 - top).abs() < 1e-9 && (r.y1 - bottom).abs() < 1e-9, "{r:?} for the text {top}..{bottom}"); // Three columns: one rule per gutter, each centred between its two columns. let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 3, |_, _| {}); assert_eq!(rules.len(), 2, "{rules:?}"); @@ -5621,16 +5742,36 @@ fn column_rules_sit_in_the_gutters_inside_the_insets() { assert!(rules.is_empty()); } +/// Column rules run from the top line's ascent to the bottom line's descent: text that fills the +/// top of a tall frame gets rules down to its bottom line, not to the bottom of the columns. +#[test] +fn column_rules_run_from_the_top_lines_ascent_to_the_bottom_lines_descent() { + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 600.0), 2, |_, _| {}); + let (top, bottom) = text_extent(&d, fid); + assert!(bottom < 300.0, "the text fills the top of the 10..580 columns: {top}..{bottom}"); + assert_eq!(rules.len(), 1, "{rules:?}"); + assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}"); + // Without lines there is no rule. + let cols = [Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 200.0, 50.0)]; + assert!(column_rule_rects(&cols, &[], &TextFrameOptions { column_rule_weight: 1.0, ..Default::default() }).is_empty()); +} + #[test] fn column_rules_centre_on_unequal_gutters_and_ignore_bad_weights() { let cols = [Rect::new(200.0, 0.0, 300.0, 50.0), Rect::new(0.0, 0.0, 100.0, 50.0), Rect::new(110.0, 0.0, 180.0, 50.0)]; + // One line in the first column, from 0 to 50. + let (d, sid, _) = doc_with("Text", Rect::new(0.0, 0.0, 100.0, 50.0), ParaAttrs::default()); + let mut line = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone(); + (line.x0, line.x1, line.ascent, line.descent, line.baseline) = (0.0, 100.0, 40.0, 10.0, 40.0); + let lines = [line]; let weight = |w: f64| TextFrameOptions { column_rule_weight: w, ..Default::default() }; - let rules = column_rule_rects(&cols, &[], &weight(1.0)); + let rules = column_rule_rects(&cols, &lines, &weight(1.0)); let centres: Vec = rules.iter().map(|r| r.center().x).collect(); assert_eq!(centres, [105.0, 190.0]); - assert!(column_rule_rects(&cols, &[], &weight(f64::NAN)).is_empty()); - assert!(column_rule_rects(&cols, &[], &weight(-1.0)).is_empty()); - assert!((column_rule_rects(&cols, &[], &weight(1e12))[0].width() - 1000.0).abs() < 1e-9); + assert!(rules.iter().all(|r| r.y0 == 0.0 && r.y1 == 50.0), "{rules:?}"); + assert!(column_rule_rects(&cols, &lines, &weight(f64::NAN)).is_empty()); + assert!(column_rule_rects(&cols, &lines, &weight(-1.0)).is_empty()); + assert!((column_rule_rects(&cols, &lines, &weight(1e12))[0].width() - 1000.0).abs() < 1e-9); } #[test] @@ -5643,9 +5784,12 @@ fn column_rules_in_right_to_left_and_vertical_frames() { // Vertical type: the columns stack top to bottom, so the rule runs across the frame. let (d, fid, rules) = ruled("縦書き", Rect::new(100.0, 100.0, 400.0, 300.0), 2, |d, sid| d.story_mut(sid).unwrap().vertical = true); assert_eq!(rules.len(), 1); + let (top, bottom) = text_extent(&d, fid); + assert!((rules[0].y0 - top).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?} for the text {top}..{bottom}"); let r = d.text_xf(d.item(fid).unwrap()).transform_rect_bbox(rules[0]); - // Text area: x 106..394, y 110..280; the gutter is centred on y 195. - assert!((r.x0 - 106.0).abs() < 1e-6 && (r.x1 - 394.0).abs() < 1e-6, "{r:?}"); + // Text area: x 106..394, y 110..280; the gutter is centred on y 195, and the one line of + // text runs from the right edge, so the rule is as long as that line is deep. + assert!(r.x0 > 106.0 && (r.x1 - 394.0).abs() < 2.0 && (r.width() - (bottom - top)).abs() < 1e-6, "{r:?}"); assert!((r.center().y - 195.0).abs() < 1e-6 && (r.height() - 2.0).abs() < 1e-6, "{r:?}"); } @@ -5664,13 +5808,15 @@ fn column_rules_break_around_paragraphs_that_span_columns() { for r in &rules { assert!(r.y1 <= top + 1e-9 || r.y0 >= bottom - 1e-9, "rule {r:?} cuts the span {top}..{bottom}"); } - // The rule resumes below the span and runs to the bottom of the columns. - assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - 380.0).abs() < 1e-9), "{rules:?}"); + // The rule resumes below the span and runs to the bottom line's descent. + let (_, text_bottom) = text_extent(&d, fid); + assert!(text_bottom < 380.0 - 1.0, "the text ends above the columns' bottom: {text_bottom}"); + assert!(rules.iter().any(|r| (r.y0 - bottom).abs() < 1e-9 && (r.y1 - text_bottom).abs() < 1e-9), "{rules:?}"); } #[test] fn column_rules_take_their_offset_insets_and_tint() { - let (d, fid, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { let fid = d.story(sid).unwrap().frames[0]; let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; o.column_rule_offset = 3.0; @@ -5678,13 +5824,22 @@ fn column_rules_take_their_offset_insets_and_tint() { o.column_rule_bottom_inset = 25.0; o.column_rule_tint = 0.4; }); - // Gutter centre 150 moved 3 pt; columns 10..180 shortened to 25..155. + // Gutter centre 150 moved 3 pt; the text's extent shortened by 15 at the top and 25 at the + // bottom. + let (top, bottom) = text_extent(&d, fid); assert_eq!(rules.len(), 1, "{rules:?}"); let r = rules[0]; - assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - 25.0).abs() < 1e-9 && (r.y1 - 155.0).abs() < 1e-9, "{r:?}"); + assert!((r.center().x - 153.0).abs() < 1e-9 && (r.y0 - (top + 15.0)).abs() < 1e-9 && (r.y1 - (bottom - 25.0)).abs() < 1e-9, "{r:?}"); let sid = d.item(fid).unwrap().text_frame().unwrap().story; let cs = compose_story(&d, sid, &ComposeOptions::default()); assert!(cs.frames[0].decos.iter().any(|dc| dc.rect == r && (dc.tint - 0.4).abs() < 1e-6)); + // A negative inset extends the rule past the text. + let (d, fid, rules) = ruled(LOREM, Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { + let fid = d.story(sid).unwrap().frames[0]; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.column_rule_top_inset = -4.0; + }); + let (top, bottom) = text_extent(&d, fid); + assert!(rules.len() == 1 && (rules[0].y0 - (top - 4.0)).abs() < 1e-9 && (rules[0].y1 - bottom).abs() < 1e-9, "{rules:?}"); // Insets that meet leave no rule. let (_, _, rules) = ruled("Text", Rect::new(0.0, 0.0, 300.0, 200.0), 2, |d, sid| { let fid = d.story(sid).unwrap().frames[0]; @@ -6697,7 +6852,7 @@ fn roman_word_break_breaks_inside_words_without_hyphens() { fn grid_doc(text: &str, para: ParaAttrs, edit: impl FnOnce(&mut Document, StoryId)) -> (Vec, (f64, f64)) { let (mut d, sid, fid) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 400.0), para); if let Some(tf) = d.item_mut(fid).and_then(|i| i.text_frame_mut()) { - tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid { start: 0.0, increment: 12.0 }); + tf.options.baseline_grid = Some(designcraft_doc::FrameBaselineGrid::new(0.0, 12.0)); } edit(&mut d, sid); // Vertical frames have no baseline grid. @@ -7407,6 +7562,37 @@ fn baseline_grid_relative_to_the_top_margin_starts_at_each_pages_margin() { assert_eq!(lines.len(), 41); } +/// A frame's custom grid (start 10, increment 13) measured from the page top, the top margin +/// (36), the frame top (100) or the top inset (6 below it). +#[test] +fn frame_baseline_grid_relative_to_page_margin_frame_or_inset() { + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative as R}; + let first_baseline = |relative_to: Option| { + let mut d = Document::new(&NewDocument { margins: designcraft_doc::Margins::uniform(36.0), ..Default::default() }); + let lid = d.default_layer(); + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, ..Default::default() }; + let (fid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 100.0, 336.0, 400.0), lid, "Grid", para).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].chars = + designcraft_doc::CharAttrs { size: Some(11.0), leading: Some(designcraft_doc::Leading::Points(13.0)), ..Default::default() }; + let tf = d.item_mut(fid).unwrap().text_frame_mut().unwrap(); + tf.options.inset = [6.0, 0.0, 0.0, 0.0]; + tf.options.baseline_grid = relative_to.map(|r| FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(10.0, 13.0) }); + // A document grid that matches none of the frame grids. + d.settings.baseline_grid.start = 0.0; + d.settings.baseline_grid.increment = 0.0; + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].baseline + }; + // Without a grid the first baseline sits an ascent below the top inset. + let natural = first_baseline(None); + assert!(natural > 106.0 && natural < 120.0, "{natural}"); + for (r, origin) in [(R::TopOfPage, 10.0), (R::TopMargin, 46.0), (R::TopOfFrame, 110.0), (R::TopInset, 116.0)] { + let want = (0..).map(|n| origin + 13.0 * f64::from(n)).find(|y| *y >= natural - 1e-9).unwrap(); + let got = first_baseline(Some(r)); + assert!((got - want).abs() < 1e-6, "{r:?}: first baseline {got}, want {want} (natural {natural})"); + } +} + /// A group's transform moves a frame from the left page of a spread to the right one: the grid /// starts at the right page's top margin. #[test] @@ -7658,3 +7844,91 @@ fn an_rtl_first_line_indent_is_at_the_right_edge() { assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0])); assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); } + +/// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt +/// above its anchor's baseline on a right-hand page. It composes against the document grid where +/// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, +/// and its page is the host's. Composed where it was stored, it snapped to a grid measured from +/// its own top edge and sat on no page. +#[test] +fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { + use designcraft_doc::anchored::AnchorRelative as R; + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + d.settings.baseline_grid.start = 36.0; + d.settings.baseline_grid.increment = 10.0; + let lid = d.default_layer(); + let page = d.spreads[1].pages[1].clone(); + assert_eq!(page.side, designcraft_doc::PageSide::Right); + // Body lines at 46, 56, 66, … (on the grid) while the frame top is at 36. + let body = "One.\nTwo.\nThree.\nFour."; + let rect = Rect::new(page.x + 108.0, 36.0, page.x + page.width - 36.0, 400.0); + let (bf, sid) = d.add_text_frame(SpreadRef::Doc(1), rect, lid, body, ParaFormat::default()).unwrap(); + d.item_mut(bf).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading; + let nine_ten = |f: &mut designcraft_doc::story::CharFormat| { + f.over.size = Some(9.0); + f.over.leading = Some(designcraft_doc::Leading::Points(10.0)); + }; + d.story_mut(sid).unwrap().format_chars(0..body.len(), nine_ten); + // The note: its own story in a 60 × 40 frame anchored in the third line. + let (nid, nsid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let text = "Note one\u{2028}note two"; + let para = ParaFormat { para: ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, ..Default::default() }; + let mut story = Story::with_text(nsid, text, para); + story.format_chars(0..text.len(), nine_ten); + story.frames.push(nid); + d.stories.insert(nsid, Arc::new(story)); + let mut note = + designcraft_doc::Item::new(nid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 40.0))); + note.content = Content::Text(designcraft_doc::TextFrame { story: nsid, options: Default::default() }); + let position = designcraft_doc::AnchorPosition::Custom { + x_relative: R::TextFrame, + y_relative: R::Anchor, + x_offset: -72.0, + y_offset: -15.0, + object_point: 0, + ref_point: 0, + keep_within_column: false, + spine_relative: false, + }; + let at = body.find("Three").unwrap(); + d.story_mut(sid).unwrap().insert_object(at, designcraft_doc::AnchoredObject::new(note, position)); + + let cache = Cache::new(); + let on_grid = |y: f64| on_grid(y, (36.0, 10.0)); + // (anchor line baseline, note baselines), in spread space. + let baselines = |d: &Document, cs: &ComposedStory| { + let host = compose_story(d, sid, &ComposeOptions::default()); + let ft = host.frame(bf).unwrap(); + let o = &ft.objects[0]; + let fxf = d.item(bf).unwrap().xf; + let anchor = (fxf * Point::new(0.0, ft.lines[o.line].baseline)).y; + let nxf = fxf * Affine::translate(o.origin.to_vec2()) * d.anchored_item(nid).unwrap().xf; + (anchor, all_lines(cs).iter().map(|l| (nxf * Point::new(0.0, l.baseline)).y).collect::>()) + }; + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 66.0), "anchor baseline {anchor}"); + assert_eq!(lines.len(), 2); + assert!(near(lines[0], anchor) && near(lines[1], anchor + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + // The note is on the host's right-hand page, with that page's rectangle. + let placed = placed::placements(&d, nsid, &mut |h| Arc::new(compose_story(&d, h, &ComposeOptions::default()))); + let spec = &frame_specs_placed(&d, nsid, &placed)[0]; + assert_eq!((spec.page, spec.right_page, spec.left_page), (Some(2), true, false)); + assert!(spec.page_rect.is_some()); + + // Host moved down 5 pt: the anchor is off the grid, and the note's first line moves down from + // 10 pt above it onto the grid line 5 pt above it. The cache composes the note again. + let xf = d.item(bf).unwrap().xf; + d.item_mut(bf).unwrap().xf = Affine::translate((0.0, 5.0)) * xf; + let (anchor, lines) = baselines(&d, &cache.get(&d, nsid, None)); + assert!(near(anchor, 71.0), "anchor baseline {anchor}"); + assert!(on_grid(lines[0]) && near(lines[0], anchor - 5.0) && near(lines[1], lines[0] + 10.0), "note baselines {lines:?}, anchor {anchor}"); + assert_eq!(baselines(&d, &compose_story(&d, nsid, &ComposeOptions::default())).1, lines); + + // The host's page turns into a left-hand page: the note is composed again for that side. + let before = cache.get(&d, nsid, None); + Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; + assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); +} diff --git a/crates/compose/src/wrap.rs b/crates/compose/src/wrap.rs index 57657213f..e1cdc5fa7 100644 --- a/crates/compose/src/wrap.rs +++ b/crates/compose/src/wrap.rs @@ -182,7 +182,7 @@ fn settled(a: &[Vec], b: &[Vec]) -> bool { /// [`neighbours`]) and of its own. pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory { let Some(st) = doc.story(sid) else { return ComposedStory { story: sid, ..Default::default() } }; - let mut specs = crate::frame_specs(doc, sid); + let mut specs = crate::frame_specs_placed(doc, sid, &opts.placed); if !others.is_empty() { let mut obs = Vec::new(); for cs in others { diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 0be5604c3..1533b8b4b 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -15,6 +15,72 @@ use crate::{Asset, AssetId, Content, Document, Item}; /// is treated as using every asset, so nothing it references is merged away or pruned. const MAX_DEPTH: usize = 64; +fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only)) +} + +fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| { + cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides())) + || story_layer_options(&cell.text, depth + 1, overrides_only) + }) + || st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only)) +} + +fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool { + depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f)) +} + +fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool { + depth > MAX_DEPTH + || st.objects.iter().any(|object| item_any(&object.item, depth + 1, f)) + || st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f)) + || st.notes.iter().chain(&st.endnotes).any(|note| story_any(¬e.text, depth + 1, f)) +} + +impl Document { + /// Whether saving needs a reader that retains per-placement graphic layer settings. + /// Exceeding the traversal budget conservatively requires the newer format. + pub fn has_graphic_layer_options(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false)) + || self.stories.values().any(|story| story_layer_options(story, 0, false)) + } + + /// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly + /// shows every layer (an override with an empty hidden list). Older readers would take that + /// for "use the file's defaults". Exceeding the traversal budget conservatively says yes. + pub fn has_explicit_all_on_pdf_layers(&self) -> bool { + let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty(); + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on)) + || self.stories.values().any(|story| story_any(story, 0, &all_on)) + } + + /// Whether any placed PDF, on document or parent spreads, in groups or anchored, has an + /// Object Layer Options choice (hidden layers or explicit all-on). Exceeding the traversal + /// budget conservatively says yes. + pub fn has_pdf_layer_overrides(&self) -> bool { + let chosen = |it: &Item| it.has_pdf_layer_override(); + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &chosen)) + || self.stories.values().any(|story| story_any(story, 0, &chosen)) + } + + /// Placements outside the ordinary document-spread frame walk used by preflight. + pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool { + self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } + + /// Includes parent items, anchored objects, graphic cells and nested notes. + pub fn has_graphic_layer_visibility_overrides(&self) -> bool { + self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true)) + || self.stories.values().any(|story| story_layer_options(story, 0, true)) + } +} + impl Asset { /// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at /// different paths stay apart even with identical bytes, since each can be updated or @@ -356,6 +422,7 @@ mod tests { fn graphic_item(id: u64, asset: u64, crop: f64) -> Item { let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); it.content = Content::Graphic(Graphic { + layer_options: None, asset: AssetId(asset), size: (10.0, 10.0), xf: Affine::translate((crop, 0.0)), diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index fa7cffc68..bb6ae3686 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -113,6 +113,32 @@ macro_rules! attr_set { }; } +mod enums; +mod language; +mod rules_lists; +mod tabs; + +pub use enums::{ + Align, Capitalization, ChangeMark, Composer, Digits, GrepStyle, GridAlign, Kerning, Leading, NestedLineStyle, NestedStyle, NestedUntil, Position, + SpanColumns, StartParagraph, TextDirection, +}; +pub use language::{capitalizes, language_subtag, language_tag}; +use rules_lists::list_level_index; +pub use rules_lists::{ + BoxBottom, BoxTop, BoxWidth, LIST_LEVELS, ListAlign, ListCounter, ListType, NumberStyle, Rule, TEXT_COLOR, expand_list_levels, expand_list_text, +}; +use tabs::sanitized_tabs; +pub use tabs::{ + MAX_MEASURE, MAX_TAB_LEADER, MAX_TAB_POSITION, MAX_TAB_STOPS, MAX_TYPE_SIZE, MIN_TYPE_SIZE, SAME_TAB_POSITION, TabAlign, TabStop, + imported_measure, imported_type_size, +}; + +mod char_attrs; +mod para_attrs; + +pub use char_attrs::{CharAttrs, CharProps}; +pub use para_attrs::{ParaAttrs, ParaProps}; + fn attr_key(snake: &'static str) -> &'static str { snake } @@ -168,1618 +194,5 @@ fn unknown_attribute(key: &str, fields: &[&str]) -> String { } } -// ---------- value types ---------- - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "value")] -pub enum Leading { - /// Auto leading (percentage of the largest size on the line, Justification → Auto Leading). - #[default] - Auto, - Points(f64), -} - -/// Paragraph direction. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TextDirection { - #[default] - LeftToRight, - RightToLeft, -} - -/// Digits (World-Ready): the figures 0–9 are drawn as these. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Digits { - /// Contextual digits; composition selects the preceding strong script/language. - #[default] - Default, - /// European (0123). - Arabic, - /// Arabic-Indic (٠١٢٣). - Hindi, - /// Extended Arabic-Indic, as used for Persian and Urdu (۰۱۲۳). - Farsi, - /// Those of the text's language (Thai, Devanagari, Bengali, Arabic, Persian…); else as typed. - Native, -} - -impl Digits { - /// The digit `c` (0–9) is drawn as, for text in `language`. - pub fn map(self, c: char, language: &str) -> char { - let d = match c { - '0'..='9' => c as u32 - '0' as u32, - '\u{0660}'..='\u{0669}' => c as u32 - 0x0660, - '\u{06F0}'..='\u{06F9}' => c as u32 - 0x06F0, - _ => return c, - }; - let zero = match self { - Digits::Default => return c, - Digits::Arabic => 0x0030, - Digits::Hindi => 0x0660, - Digits::Farsi => 0x06F0, - Digits::Native => { - let l = language.to_ascii_lowercase(); - match () { - _ if l.starts_with("arabic") || l == "ar" || l.starts_with("ar-") => 0x0660, - _ if l.starts_with("persian") - || l.starts_with("farsi") - || l.starts_with("urdu") - || l == "fa" - || l.starts_with("fa-") - || l == "ur" - || l.starts_with("ur-") => - { - 0x06F0 - } - _ if l.starts_with("hindi") || l.starts_with("marathi") || l.starts_with("nepali") || l.starts_with("sanskrit") => 0x0966, - _ if l.starts_with("bengali") => 0x09E6, - _ if l.starts_with("gujarati") => 0x0AE6, - _ if l.starts_with("punjabi") => 0x0A66, - _ if l.starts_with("tamil") => 0x0BE6, - _ if l.starts_with("telugu") => 0x0C66, - _ if l.starts_with("kannada") => 0x0CE6, - _ if l.starts_with("malayalam") => 0x0D66, - _ if l.starts_with("thai") => 0x0E50, - _ if l.starts_with("lao") => 0x0ED0, - _ if l.starts_with("tibetan") => 0x0F20, - _ if l.starts_with("burmese") => 0x1040, - _ if l.starts_with("khmer") => 0x17E0, - _ => return c, - } - } - }; - char::from_u32(zero + d).unwrap_or(c) - } -} - -/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings? -/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps -/// text, never for a sentence's, a title's or an index heading's first letter. -pub fn capitalizes(c: char) -> bool { - !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') -} - -/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: -/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`) -/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking, -/// fallback fonts). A variant we don't know falls back to its language ("English: Australian" → -/// `en`); an unknown language (or "[No Language]") has none. Case, punctuation and IDML's `$ID/` -/// don't matter. Locale codes (`ll`, `ll_CC`, `ll-CC`, with a reform year after them as in -/// `de_DE_2006`) are their tag (`de-DE`), and Chinese also goes by its other common spellings -/// ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", "zh-Hant"). -pub fn language_tag(language: &str) -> Option<&'static str> { - let language = language.trim(); - let language = language.strip_prefix("$ID/").unwrap_or(language); - let l = language.to_lowercase(); - let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); - let first = words.first().copied().unwrap_or(""); - let has = |any: &[&str]| words.iter().any(|w| any.contains(w)); - if first == "zh" || has(&["chinese"]) { - // The script decides; then the name; then the region. - return Some(if has(&["hans"]) { - "zh-Hans" - } else if has(&["hant"]) { - "zh-Hant" - } else if has(&["simplified", "cn", "sg"]) { - "zh-Hans" - } else if has(&["traditional", "tw", "hk", "mo"]) { - "zh-Hant" - } else { - "zh" - }); - } - if let Some(tag) = locale_code(&words) { - return intern_tag(tag); - } - let variant = match words.join(" ").as_str() { - "english usa" => Some("en-US"), - "english uk" => Some("en-GB"), - "english canadian" => Some("en-CA"), - "german swiss 2006 reform" | "german swiss" => Some("de-CH"), - "french swiss" => Some("fr-CH"), - "italian swiss" => Some("it-CH"), - "spanish castilian" => Some("es-ES"), - "french canadian" => Some("fr-CA"), - "portuguese brazilian" => Some("pt-BR"), - "norwegian bokmål" | "norwegian bokmal" => Some("nb"), - "norwegian nynorsk" => Some("nn"), - _ => None, - }; - if variant.is_some() { - return variant; - } - Some(match first { - "english" => "en", - "german" => "de", - "french" => "fr", - "spanish" => "es", - "catalan" => "ca", - "italian" => "it", - "portuguese" => "pt", - "dutch" => "nl", - "danish" => "da", - "swedish" => "sv", - "norwegian" => "no", - "finnish" => "fi", - "polish" => "pl", - "czech" => "cs", - "slovak" => "sk", - "hungarian" => "hu", - "romanian" => "ro", - "croatian" => "hr", - "lithuanian" => "lt", - "greek" => "el", - "russian" => "ru", - "ukrainian" => "uk", - "bulgarian" => "bg", - "serbian" => "sr", - "macedonian" => "mk", - "turkish" => "tr", - "japanese" => "ja", - "korean" => "ko", - "arabic" => "ar", - "persian" | "farsi" => "fa", - "urdu" => "ur", - "hebrew" => "he", - "georgian" => "ka", - _ => return None, - }) -} - -/// The tag of a locale code split into lowercase `words`: a language (2 or 3 letters), then -/// optionally a region (2 letters or 3 digits) and a reform year (4 digits) → `ll` or `ll-CC`. -fn locale_code(words: &[&str]) -> Option { - let letters = |w: &str, n: std::ops::RangeInclusive| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()); - let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit()); - match *words { - [lang] if letters(lang, 2..=3) => Some(lang.to_string()), - [lang, region] | [lang, region, _] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => { - if let [_, _, year] = *words - && !digits(year, 4) - { - return None; - } - Some(format!("{lang}-{}", region.to_ascii_uppercase())) - } - _ => None, - } -} - -/// `tag` as a `&'static str`: each distinct tag is kept once for the process. Language names come -/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`). -fn intern_tag(tag: String) -> Option<&'static str> { - const MAX_TAGS: usize = 512; - static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(t) = tags.iter().find(|t| **t == tag) { - return Some(t); - } - if tags.len() >= MAX_TAGS { - return None; - } - let t: &'static str = Box::leak(tag.into_boxed_str()); - tags.push(t); - Some(t) -} - -/// The language subtag of a BCP 47 tag (`de-DE` → `de`, `zh-Hant` → `zh`). -pub fn language_subtag(tag: &str) -> &str { - tag.split('-').next().unwrap_or(tag) -} - -/// A tracked change on text. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ChangeMark { - #[default] - None, - Inserted, - Deleted, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "value")] -pub enum Kerning { - #[default] - Metrics, - Optical, - /// Zero ("0" in the kerning menu). - None, - /// Manual pair kerning in 1/1000 em (applies after this character). - Manual(f64), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Capitalization { - #[default] - Normal, - AllCaps, - SmallCaps, - OpenTypeAllSmallCaps, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Position { - #[default] - Normal, - Superscript, - Subscript, - OtSuperscript, - OtSubscript, - OtNumerator, - OtDenominator, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Align { - #[default] - Left, - Center, - Right, - LeftJustified, - CenterJustified, - RightJustified, - FullyJustified, - TowardsSpine, - AwayFromSpine, -} - -impl Align { - pub fn is_justified(self) -> bool { - matches!(self, Align::LeftJustified | Align::CenterJustified | Align::RightJustified | Align::FullyJustified) - } - pub const ALL: [Align; 9] = [ - Align::Left, - Align::Center, - Align::Right, - Align::LeftJustified, - Align::CenterJustified, - Align::RightJustified, - Align::FullyJustified, - Align::TowardsSpine, - Align::AwayFromSpine, - ]; - pub fn label(self) -> &'static str { - match self { - Align::Left => "Align left", - Align::Center => "Align center", - Align::Right => "Align right", - Align::LeftJustified => "Justify with last line aligned left", - Align::CenterJustified => "Justify with last line aligned center", - Align::RightJustified => "Justify with last line aligned right", - Align::FullyJustified => "Justify all lines", - Align::TowardsSpine => "Align towards spine", - Align::AwayFromSpine => "Align away from spine", - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Composer { - /// Total-fit (Knuth–Plass) over the whole paragraph. - #[default] - Paragraph, - /// Greedy, line by line. - SingleLine, - /// The Japanese paragraph composer: total fit, with the CJK leading model and character - /// alignment. - Japanese, - /// The Japanese single-line composer. - JapaneseSingleLine, -} - -impl Composer { - /// Breaks lines greedily, one at a time. - pub fn single_line(self) -> bool { - matches!(self, Composer::SingleLine | Composer::JapaneseSingleLine) - } - - /// A Japanese composer: lines follow the leading model ([`crate::cjk::LeadingModel`]) and - /// characters their [`crate::cjk::CharacterAlignment`]. The others set lines baseline to - /// baseline with every character on the Roman baseline. - pub fn japanese(self) -> bool { - matches!(self, Composer::Japanese | Composer::JapaneseSingleLine) - } -} - -/// What a nested style runs to (Drop Caps and Nested Styles). -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "chars")] -pub enum NestedUntil { - Sentences, - #[default] - Words, - Characters, - Letters, - Digits, - Tab, - ForcedLineBreak, - EmSpace, - EnSpace, - /// The paragraph's drop cap characters. - Dropcap, - /// Any of these characters. - Chars(String), -} - -/// A character style applied from the start of the paragraph through (or up to) the `count`-th -/// delimiter; nested styles follow each other. -/// A nested line style: `style` for the next `lines` lines. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct NestedLineStyle { - pub style: String, - pub lines: u32, -} - -impl Default for NestedLineStyle { - fn default() -> Self { - NestedLineStyle { style: String::new(), lines: 1 } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct NestedStyle { - pub style: String, - /// Through (include the delimiter) or up to (stop before it). - pub through: bool, - pub count: u32, - pub until: NestedUntil, -} - -impl Default for NestedStyle { - fn default() -> Self { - NestedStyle { style: String::new(), through: true, count: 1, until: NestedUntil::Words } - } -} - -impl NestedStyle { - /// The drop cap's character style as a nested style: `style` through 1 drop cap. - pub fn drop_cap(style: &str) -> Self { - NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } - } - - /// Is this the form a drop cap's character style takes at the start of a nested style list? - pub fn is_drop_cap(&self) -> bool { - self.until == NestedUntil::Dropcap && self.through && self.count == 1 - } -} - -/// A character style applied to every match of a regular expression in the paragraph. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct GrepStyle { - pub style: String, - pub pattern: String, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GridAlign { - #[default] - None, - AllLines, - FirstLineOnly, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StartParagraph { - #[default] - Anywhere, - NextColumn, - NextFrame, - NextPage, - NextOddPage, - NextEvenPage, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind", content = "columns")] -pub enum SpanColumns { - #[default] - Single, - /// Span `n` columns (0 = all). - Span(u32), - /// Split into `n` sub-columns. - Split(u32), -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TabAlign { - #[default] - Left, - Center, - Right, - /// Align on a character (default `.`). - Char, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TabStop { - /// A file that held a non-finite position (written as `null`) reads it back as NaN, which - /// [`TabStop::sanitized_list`] drops. - #[serde(deserialize_with = "position_or_nan")] - pub position: f64, - #[serde(default)] - pub align: TabAlign, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub leader: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub align_on: String, -} - -/// The colour of a paragraph rule that follows the paragraph's text colour. -pub const TEXT_COLOR: &str = "Text Color"; - -/// Most stops a paragraph holds. -pub const MAX_TAB_STOPS: usize = 100; -/// Furthest stop position: 216 in, the largest page. -pub const MAX_TAB_POSITION: f64 = 15_552.0; -/// Longest leader, in characters. -pub const MAX_TAB_LEADER: usize = 8; -/// Stops closer than this share a position. -pub const SAME_TAB_POSITION: f64 = 0.01; - -impl TabStop { - /// A finite position clamped to `0..=MAX_TAB_POSITION`. - pub fn clamp_position(v: f64) -> Result { - if v.is_finite() { Ok(v.clamp(0.0, MAX_TAB_POSITION)) } else { Err("position must be a finite number".into()) } - } - - /// A leader: up to [`MAX_TAB_LEADER`] characters, no control characters. - pub fn check_leader(v: &str) -> Result<(), String> { - if v.chars().count() > MAX_TAB_LEADER || v.chars().any(char::is_control) { - return Err(format!("`leader`: at most {MAX_TAB_LEADER} printable characters")); - } - Ok(()) - } - - /// An align-on character: one printable character or none (a decimal point). - pub fn check_align_on(v: &str) -> Result<(), String> { - if v.chars().count() > 1 || v.chars().any(char::is_control) { - return Err("`alignOn`: one printable character".into()); - } - Ok(()) - } - - /// A tab-stop list as the Tabs commands keep it: at most [`MAX_TAB_STOPS`] stops, positions - /// clamped, leaders and align-on characters checked, a char stop aligning on `.` unless it - /// names a character, sorted by position, and one stop per position (the later one wins). - pub fn checked_list(mut tabs: Vec) -> Result, String> { - if tabs.len() > MAX_TAB_STOPS { - return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops")); - } - for t in &mut tabs { - t.position = Self::clamp_position(t.position)?; - Self::check_leader(&t.leader)?; - Self::check_align_on(&t.align_on)?; - } - Ok(Self::normalized(tabs)) - } - - /// A tab-stop list read from a file, repaired to what [`checked_list`](Self::checked_list) - /// accepts: stops with a non-finite position dropped, positions clamped, control characters - /// removed from leaders and align-on characters and both cut to their limits, sorted, one stop - /// per position (the later one wins), and the first [`MAX_TAB_STOPS`] by position kept. - pub fn sanitized_list(tabs: Vec) -> Vec { - let tabs = tabs - .into_iter() - .filter_map(|mut t| { - t.position = Self::clamp_position(t.position).ok()?; - if Self::check_leader(&t.leader).is_err() { - t.leader = t.leader.chars().filter(|c| !c.is_control()).take(MAX_TAB_LEADER).collect(); - } - if Self::check_align_on(&t.align_on).is_err() { - t.align_on = t.align_on.chars().filter(|c| !c.is_control()).take(1).collect(); - } - Some(t) - }) - .collect(); - let mut out = Self::normalized(tabs); - out.truncate(MAX_TAB_STOPS); - out - } - - /// Finite, clamped stops sorted by position, one per position: of stops closer than - /// [`SAME_TAB_POSITION`], the later one in `tabs` wins. A char stop with no character aligns - /// on `.`. - fn normalized(tabs: Vec) -> Vec { - let mut stops: Vec<(usize, TabStop)> = tabs.into_iter().enumerate().collect(); - stops.sort_by(|a, b| a.1.position.total_cmp(&b.1.position)); - let mut out: Vec<(usize, TabStop)> = Vec::with_capacity(stops.len()); - for (i, mut t) in stops { - if t.align == TabAlign::Char && t.align_on.is_empty() { - t.align_on = ".".into(); - } - match out.last_mut() { - Some(last) if t.position - last.1.position < SAME_TAB_POSITION => { - if i > last.0 { - *last = (i, t); - } - } - _ => out.push((i, t)), - } - } - out.into_iter().map(|(_, t)| t).collect() - } -} - -/// A stop's position; `null` (how JSON writes a non-finite number) reads as NaN. -fn position_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) -} - -/// A paragraph's tab-stop list in either attribute form, as read from a file. -trait FileTabs: Sized { - fn sanitized(self) -> Self; -} - -impl FileTabs for Vec { - fn sanitized(self) -> Self { - TabStop::sanitized_list(self) - } -} - -impl FileTabs for Option> { - fn sanitized(self) -> Self { - self.map(TabStop::sanitized_list) - } -} - -/// Deserializes a `tabs` field through [`TabStop::sanitized_list`]: a saved document's tab lists -/// get the limits the Tabs commands keep. -fn sanitized_tabs<'de, D: serde::Deserializer<'de>, T: Deserialize<'de> + FileTabs>(d: D) -> Result { - T::deserialize(d).map(FileTabs::sanitized) -} - -/// A paragraph rule (Rule Above / Rule Below). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Rule { - pub on: bool, - pub weight: f64, - /// A swatch, or [`TEXT_COLOR`]. - pub color: String, - pub tint: f32, - /// Width: column (true) or text (false). - pub column_width: bool, - pub offset: f64, - pub left_indent: f64, - pub right_indent: f64, -} - -impl Default for Rule { - fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } - } -} - -/// How wide a paragraph border or shading box is: the column, or the text's longest line. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BoxWidth { - #[default] - Column, - Text, -} - -/// Where a paragraph border or shading box starts above the first line (before its top offset). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BoxTop { - /// The tallest ascent on the line. - #[default] - Ascent, - Baseline, - /// The top of the em box. - EmBox, - /// The top of the line's leading. - Leading, -} - -/// Where a paragraph border or shading box ends below the last line (before its bottom offset). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BoxBottom { - /// The deepest descent on the line. - #[default] - Descent, - Baseline, - /// The bottom of the em box. - EmBox, -} - -/// Where a bullet or number sits against the first line's start (the left and first-line -/// indents): it starts there, is centred on it, or ends there. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ListAlign { - #[default] - Left, - Center, - Right, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ListType { - #[default] - None, - Bullets, - Numbers, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum NumberStyle { - #[default] - Arabic, - UpperRoman, - LowerRoman, - UpperLetters, - LowerLetters, - /// 01, 02, … - ArabicLeadingZero, - /// 001, 002, … - ArabicThreeDigits, - /// 0001, 0002, … - ArabicFourDigits, - /// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes. - Symbols, - /// 一, 二, 三, …: Chinese numerals written digit by digit, so 10 is 一〇 and 38 is 三八. - Kanji, -} - -/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman -/// numerals, letters, symbols), so a hostile start number can't build a huge label. -const MAX_REPEATED_FORM: u32 = 3999; - -impl NumberStyle { - pub fn format(self, n: u32) -> String { - if n > MAX_REPEATED_FORM - && matches!( - self, - NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols - ) - { - return n.to_string(); - } - match self { - NumberStyle::Arabic => n.to_string(), - NumberStyle::ArabicLeadingZero => format!("{n:02}"), - NumberStyle::ArabicThreeDigits => format!("{n:03}"), - NumberStyle::ArabicFourDigits => format!("{n:04}"), - NumberStyle::UpperRoman => roman(n), - NumberStyle::LowerRoman => roman(n).to_lowercase(), - NumberStyle::UpperLetters => letters(n), - NumberStyle::LowerLetters => letters(n).to_lowercase(), - NumberStyle::Symbols => { - const SYM: [char; 6] = ['*', '†', '‡', '§', '¶', '‖']; - let n = n.max(1) as usize - 1; - std::iter::repeat_n(SYM[n % SYM.len()], n / SYM.len() + 1).collect() - } - NumberStyle::Kanji => kanji_digits(n), - } - } -} - -/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …), -/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no -/// meaning in a label are dropped; a label never holds a paragraph break. -pub fn expand_list_text(s: &str, number: &str) -> String { - expand_list_levels(s, number, &[]) -} - -/// [`expand_list_text`] with `levels[k]` in for `^1`…`^9` (level k + 1's current number; a level -/// past the end is dropped). -pub fn expand_list_levels(s: &str, number: &str, levels: &[String]) -> String { - let mut out = String::with_capacity(s.len() + number.len()); - let mut it = s.chars(); - while let Some(c) = it.next() { - if c != '^' { - if c != '\n' && c != '\r' { - out.push(c); - } - continue; - } - let Some(m) = it.next() else { - out.push('^'); - break; - }; - let r = match m { - '#' => { - out.push_str(number); - continue; - } - d @ '1'..='9' => { - let k = d as usize - '1' as usize; - if let Some(l) = levels.get(k) { - out.push_str(l); - } - continue; - } - '^' => '^', - 't' => '\t', - 'y' => crate::story::RIGHT_INDENT_TAB, - 'i' => crate::story::INDENT_HERE, - 'n' => crate::story::FORCED_LINE_BREAK, - 'm' => '\u{2003}', - '>' => '\u{2002}', - 'f' => '\u{2001}', - '|' => '\u{200A}', - '<' => '\u{2009}', - '/' => '\u{2007}', - '.' => '\u{2008}', - 's' => '\u{A0}', - 'S' => '\u{202F}', - '_' => '\u{2014}', - '=' => '\u{2013}', - '~' => '\u{2011}', - '{' => '\u{201C}', - '}' => '\u{201D}', - '[' => '\u{2018}', - ']' => '\u{2019}', - '"' => '"', - '\'' => '\'', - _ => continue, - }; - out.push(r); - } - out -} - -fn kanji_digits(n: u32) -> String { - const DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; - n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| DIGITS.get(d as usize)).copied().unwrap_or(c)).collect() -} - -fn roman(mut n: u32) -> String { - if n == 0 { - return "0".into(); - } - let t = [ - (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(); - for (v, r) in t { - while n >= v { - s.push_str(r); - n -= v; - } - } - s -} - -/// A, B, …, Z, AA, BB, … (InDesign's letter numbering repeats the letter). -fn letters(n: u32) -> String { - if n == 0 { - return String::new(); - } - let i = (n - 1) % 26; - let reps = (n - 1) / 26 + 1; - std::iter::repeat_n(char::from(b'A' + i as u8), reps as usize).collect() -} - -attr_set! { - /// Character attributes (Character panel / character styles / the character part of paragraph styles). - CharAttrs / CharProps { - font_family: String = "Source Serif 4".into(), - font_style: String = "Regular".into(), - /// Point size. - size: f64 = 12.0, - leading: Leading = Leading::Auto, - kerning: Kerning = Kerning::Metrics, - /// Tracking in 1/1000 em. - tracking: f64 = 0.0, - /// Explicit CJK aki, in em; None restores automatic spacing. - leading_aki: Option = None => "deserialize_explicit_value", - trailing_aki: Option = None => "deserialize_explicit_value", - /// Proportional character compression, 0..1. - tsume: f64 = 0.0, - /// Target em cells for the contiguous formatted group (0 disables jidori). - jidori: u32 = 0, - character_alignment: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, - leading_model: crate::cjk::LeadingModel = crate::cjk::LeadingModel::Roman, - /// The kenten mark's text: a custom mark, or the preset of `kenten_kind` (see - /// [`crate::cjk_settings::kenten_mark`]); empty is the sesame dot. - kenten_character: String = String::new(), - /// Horizontal / vertical scale, 1.0 = 100%. - h_scale: f64 = 1.0, - v_scale: f64 = 1.0, - baseline_shift: f64 = 0.0, - /// Skew (false italic), degrees. - skew: f64 = 0.0, - fill: String = "[Black]".into(), - fill_tint: f32 = 1.0, - stroke: String = "[None]".into(), - stroke_tint: f32 = 1.0, - stroke_weight: f64 = 1.0, - /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the - /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. - stroke_behind_fill: bool = false, - capitalization: Capitalization = Capitalization::Normal, - position: Position = Position::Normal, - underline: bool = false, - strikethrough: bool = false, - /// Underline Options: weight and offset below the baseline (`None` = from the size), - /// colour (empty = the text colour) and tint. - underline_weight: Option = None => "deserialize_explicit_value", - underline_offset: Option = None => "deserialize_explicit_value", - underline_color: String = String::new(), - underline_tint: f32 = 1.0, - /// Strikethrough Options: offset above the baseline. - strikethrough_weight: Option = None => "deserialize_explicit_value", - strikethrough_offset: Option = None => "deserialize_explicit_value", - strikethrough_color: String = String::new(), - strikethrough_tint: f32 = 1.0, - glyph_form: String = String::new(), - ligatures: bool = true, - no_break: bool = false, - /// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line. - tate_chu_yoko: bool = false, - tate_chu_yoko_x_offset: f64 = 0.0, - tate_chu_yoko_y_offset: f64 = 0.0, - /// Ruby: the reading set small above the text (to its right in vertical text). Group ruby - /// sets it over the whole run; per-character ruby holds one reading per character, - /// separated by U+3000. Its other settings are the `ruby_*` fields below. - ruby: String = String::new(), - /// Kenten: an emphasis dot above each character. - kenten: bool = false, - /// Warichu: the run is set in smaller lines stacked inside one line height. - warichu: bool = false, - /// How many warichu lines (InDesign's minimum is 2). - warichu_lines: u32 = 2, - /// Warichu size as a percentage of the parent size. - warichu_size: f64 = 50.0, - /// Extra points between warichu baselines. 0 is automatic (one small em). - warichu_line_spacing: f64 = 0.0, - warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto, - /// Minimum characters on a warichu line before a break, and on the line after it. - warichu_chars_before_break: u32 = 1, - warichu_chars_after_break: u32 = 1, - // Kenten Settings / Kenten Color (`kenten` switches the marks on). - /// The mark; [`crate::cjk_settings::KentenKind::Custom`] draws `kenten_character`. - kenten_kind: crate::cjk_settings::KentenKind = crate::cjk_settings::KentenKind::SesameDot, - /// Font of a custom mark (empty = the text's font) and its style. - kenten_font: String = String::new(), - kenten_font_style: String = String::new(), - /// Mark size in points; `None` is half the text size. - kenten_size: Option = None, - /// Horizontal / vertical scale of the mark, 1.0 = 100%. - kenten_x_scale: f64 = 1.0, - kenten_y_scale: f64 = 1.0, - /// Points between the text's em box and the mark's. - kenten_distance: f64 = 0.0, - kenten_position: crate::cjk_settings::KentenPosition = crate::cjk_settings::KentenPosition::AboveRight, - kenten_alignment: crate::cjk_settings::KentenAlignment = crate::cjk_settings::KentenAlignment::Center, - /// How a custom mark was entered (IDML `KentenCharacterSet`), kept for interchange. - kenten_character_set: String = String::new(), - /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = - /// the text's). - kenten_fill: String = String::new(), - kenten_fill_tint: Option = None, - kenten_stroke: String = String::new(), - kenten_stroke_tint: Option = None, - kenten_stroke_weight: Option = None, - kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. - /// Compression in percent of the em (0 = off). - shatai_magnification: f64 = 0.0, - /// Degrees from the line direction, counter-clockwise. - shatai_angle: f64 = 45.0, - /// Turn the compressed character back so its edge along the line stays along the line. - shatai_adjust_rotation: bool = false, - /// Fit the advance to the compressed em box. - shatai_adjust_tsume: bool = true, - // Ruby settings (see [`crate::ruby`]). Defaults are the IDML text defaults. - ruby_type: crate::ruby::RubyType = crate::ruby::RubyType::Group, - /// How a ruby shorter than its parent is spread over it. - ruby_alignment: crate::ruby::RubyAlignment = crate::ruby::RubyAlignment::Jis, - ruby_position: crate::ruby::RubyPosition = crate::ruby::RubyPosition::AboveRight, - /// Points along the line, and away from the parent text across it. - ruby_x_offset: f64 = 0.0, - ruby_y_offset: f64 = 0.0, - /// Empty: the parent's font and style. - ruby_font: String = String::new(), - ruby_font_style: String = String::new(), - /// Points; `None` is half the parent size. - ruby_font_size: Option = None, - /// 1.0 = 100%. - ruby_x_scale: f64 = 1.0, - ruby_y_scale: f64 = 1.0, - /// Use the font's ruby glyphs (OpenType `ruby`). - ruby_open_type_pro: bool = true, - /// Tate-chu-yoko in vertical ruby for runs of up to this many digits (0 = off). - ruby_auto_tcy_digits: u32 = 0, - ruby_auto_tcy_include_roman: bool = false, - ruby_auto_tcy_auto_scale: bool = true, - /// IDML `RubyOverhang`, kept for round trips; `ruby_overhang_amount` sets the overhang. - ruby_overhang: bool = false, - ruby_overhang_amount: crate::ruby::RubyOverhang = crate::ruby::RubyOverhang::OneRuby, - ruby_parent_spacing: crate::ruby::RubyParentSpacing = crate::ruby::RubyParentSpacing::Aki121, - /// A longer ruby at a line's start or end is set flush with the line edge. - ruby_auto_align: bool = true, - /// A longer ruby is narrowed first, down to `ruby_scaling_min` (1.0 = 100%). - ruby_auto_scaling: bool = false, - ruby_scaling_min: f64 = 0.66, - /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. - ruby_fill: String = String::new(), - ruby_fill_tint: Option = None, - ruby_stroke: String = String::new(), - ruby_stroke_tint: Option = None, - ruby_stroke_weight: Option = None, - ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, - /// Digits (World-Ready): how 0–9 are drawn. - digits: Digits = Digits::Default, - character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default, - /// Character-level permission, additionally gated by paragraph kashidas. - allow_kashidas: bool = true, - diacritic_position: crate::arabic::DiacriticPosition = crate::arabic::DiacriticPosition::Default, - /// Additional mark offsets, in thousandths of an em. Positive y is up. - diacritic_x_offset: f64 = 0.0, - diacritic_y_offset: f64 = 0.0, - /// IDML contextual-form request, separate from general OpenType features. - positional_form: String = String::new(), - language: String = "English: USA".into(), - /// Additional OpenType features, e.g. `["onum", "ss01"]`. - otf_features: Vec = Vec::new(), - /// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text). - conditions: Vec = Vec::new(), - /// Track Changes: text added or deleted while tracking (deleted text isn't laid out). - change: ChangeMark = ChangeMark::None, - /// XML: the element this text is tagged with (Tags panel, inline). - xml_tag: String = String::new(), - } -} - -attr_set! { - /// Paragraph attributes (Paragraph panel / paragraph styles). - ParaAttrs / ParaProps { - align: Align = Align::Left, - /// Paragraph Direction (World-Ready): the base direction lines are ordered in. - direction: TextDirection = TextDirection::LeftToRight, - left_indent: f64 = 0.0, - right_indent: f64 = 0.0, - first_line_indent: f64 = 0.0, - last_line_indent: f64 = 0.0, - space_before: f64 = 0.0, - space_after: f64 = 0.0, - /// Space between consecutive paragraphs of the same style; replaces space after/before there (None = ignore). - same_para_style_spacing: Option = None => "deserialize_explicit_value", - /// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never - /// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the - /// character style `drop_cap_style` ("" or `[None]`: the text's own formatting). - drop_cap_lines: u32 = 0, - drop_cap_chars: u32 = 0, - drop_cap_style: String = String::new(), - /// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side - /// bearing is taken off), and the lines beside it start where its advance ends. - drop_cap_align_left: bool = true, - /// Scale for Descenders (kept and round-tripped; doesn't change the layout). - drop_cap_scale_descenders: bool = false, - /// Nested styles, in order from the paragraph start. - nested_styles: Vec = Vec::new(), - /// GREP styles: a character style for every match of a pattern (applied after nested). - grep_styles: Vec = Vec::new(), - /// Nested line styles: a character style for the paragraph's first lines, in order. - nested_line_styles: Vec = Vec::new(), - grid_align: GridAlign = GridAlign::None, - composer: Composer = Composer::Paragraph, - // Hyphenation - hyphenate: bool = true, - hyph_min_word: u32 = 5, - hyph_after_first: u32 = 2, - hyph_before_last: u32 = 2, - /// Max consecutive hyphenated lines (0 = unlimited). - hyph_limit: u32 = 3, - hyph_zone: f64 = 36.0, - hyph_capitalized: bool = true, - hyph_last_word: bool = true, - hyph_across_column: bool = true, - /// Better spacing (0) ↔ fewer hyphens (1). - hyph_weight: f64 = 0.5, - // Justification (percent as fractions) - word_space_min: f64 = 0.8, - word_space_desired: f64 = 1.0, - word_space_max: f64 = 1.33, - letter_space_min: f64 = 0.0, - letter_space_desired: f64 = 0.0, - letter_space_max: f64 = 0.0, - glyph_scale_min: f64 = 1.0, - glyph_scale_desired: f64 = 1.0, - glyph_scale_max: f64 = 1.0, - auto_leading: f64 = 1.2, - single_word_justify: Align = Align::FullyJustified, - /// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces. - kashidas: bool = true, - /// Preserved vendor policy; non-default policies require a dedicated composer. - arabic_justification: String = String::new(), - paragraph_kashida_width: Option = None => "deserialize_explicit_value", - /// Korean text breaks between any two syllables (character-based breaking); otherwise - /// it breaks at spaces, word by word. - korean_char_breaks: bool = false, - // Keeps - keep_with_next: u32 = 0, - keep_lines_together: bool = false, - /// All lines in paragraph (true) or at start/end of paragraph (false). - keep_all_lines: bool = false, - keep_first: u32 = 2, - keep_last: u32 = 2, - start_paragraph: StartParagraph = StartParagraph::Anywhere, - span_columns: SpanColumns = SpanColumns::Single, - /// Split Columns: the space between sub-columns and the inset from the column's edges. - split_inside_gutter: f64 = 6.0, - split_outside_gutter: f64 = 0.0, - rule_above: Rule = Rule::default(), - rule_below: Rule = Rule::default(), - #[serde(deserialize_with = "sanitized_tabs")] - tabs: Vec = Vec::new(), - list_type: ListType = ListType::None, - /// Named list (Define Lists) the numbers belong to ("" = the story's own list). - list_name: String = String::new(), - /// Start At: restart the numbering at this number. - start_at: Option = None => "deserialize_explicit_value", - bullet_char: String = "\u{2022}".into(), - number_style: NumberStyle = NumberStyle::Arabic, - /// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in - /// [`expand_list_text`]. - number_expression: String = "^#.^t".into(), - /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). - list_separator: String = "\t".into(), - /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away - /// again with the list (an indent the paragraph had before stays). - list_hang: bool = false, - balance_ragged: bool = false, - optical_margin: bool = false, - /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. - kinsoku: Option = None => "deserialize_explicit_value", - /// Preserved spacing-table reference; compatibility audit reports unsupported composition. - mojikumi: String = String::new(), - /// Preserved priority policy; the current breaker does not implement these priorities. - kinsoku_type: String = String::new(), - kinsoku_hang: crate::cjk::KinsokuHang = crate::cjk::KinsokuHang::None, - bunri_kinshi: bool = false, - rensuuji: bool = true, - treat_ideographic_space_as_space: bool = false, - // Auto Tate-chu-yoko, Japanese Composition and Grid Settings. - /// Auto tate-chu-yoko: in vertical text, runs of up to this many half-width digits are set - /// across one em (0 = off). - auto_tcy: u32 = 0, - /// Auto tate-chu-yoko also takes runs of half-width roman letters. - auto_tcy_include_roman: bool = false, - /// Rotate Roman Characters in Vertical Text (縦組み中の欧文回転): half-width characters - /// stand upright one by one instead of lying along the line. - rotate_roman: bool = false, - /// Roman word break (欧文泣き別れ): roman words may break between any two letters, - /// without a hyphen. - roman_word_break: bool = false, - /// The point of a line that sits on the grid while `grid_align` is on. - grid_reference: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, - /// Gyoudori (行取り): grid lines each line takes (0 = as many as its leading needs). - grid_gyoudori: u32 = 0, - /// Paragraph gyoudori: the paragraph as a whole, not each line, takes `grid_gyoudori` lines. - paragraph_gyoudori: bool = false, - /// Paragraph shading, and how far it reaches past the text: top, left, bottom, right. - shading_on: bool = false, - shading_color: String = "[Black]".into(), - shading_tint: f32 = 0.2, - shading_offsets: [f64; 4] = [0.0; 4], - /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the - /// text (top, left, bottom, right). - border_on: bool = false, - border_weights: [f64; 4] = [1.0; 4], - border_color: String = "[Black]".into(), - border_tint: f32 = 1.0, - border_offsets: [f64; 4] = [0.0; 4], - /// The border's stroke type (a document stroke style by name, or a built-in type), cap and - /// join. - border_type: crate::item::StrokeType = crate::item::StrokeType::Solid, - border_cap: crate::item::Cap = crate::item::Cap::Butt, - border_join: crate::item::Join = crate::item::Join::Miter, - /// Corner shape and size: top left, top right, bottom right, bottom left. - border_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], - border_width: BoxWidth = BoxWidth::Column, - border_top: BoxTop = BoxTop::Ascent, - border_bottom: BoxBottom = BoxBottom::Descent, - /// A paragraph split across frames or columns gets a closed box in each part (otherwise the - /// box is open where it splits). - border_display_if_splits: bool = false, - /// Consecutive paragraphs with the same border and shading share one box. - border_merge: bool = true, - /// The colour between the dashes, dots or stripes of a border that isn't solid. - border_gap_color: String = "[None]".into(), - border_gap_tint: f32 = 1.0, - /// Shading corners: top left, top right, bottom right, bottom left. - shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], - shading_width: BoxWidth = BoxWidth::Column, - shading_top: BoxTop = BoxTop::Ascent, - shading_bottom: BoxBottom = BoxBottom::Descent, - /// The shading stops at the edges of the text frame. - shading_clip: bool = false, - /// The shading shows on screen only: not printed or exported. - shading_nonprinting: bool = false, - /// Least space between a spanning or split paragraph and the column text above and below it. - span_space_before: f64 = 0.0, - span_space_after: f64 = 0.0, - /// List level (1–9): each level numbers on its own, and `^1`…`^9` in a number's text are the - /// current numbers of those levels. - list_level: u32 = 1, - /// Character styles of the bullet and of the number. - bullet_char_style: String = crate::story::NO_CHAR_STYLE.into(), - number_char_style: String = crate::story::NO_CHAR_STYLE.into(), - bullet_align: ListAlign = ListAlign::Left, - number_align: ListAlign = ListAlign::Left, - /// A tab stop for the text after the bullet or number, from the column's left edge. - list_tab: Option = None, - /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise - /// that level) starts this level's numbering again. - restart_numbers: bool = true, - restart_after_level: u32 = 0, - /// The bullet's font family and style ("" = the paragraph's). - bullet_font: String = String::new(), - bullet_font_style: String = String::new(), - } -} - -impl ParaAttrs { - /// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the - /// rule fields to change; the others keep their values in `current` (the resolved attributes - /// of the paragraph or style being edited). A `tabs` list is checked and sorted as - /// [`TabStop::checked_list`] does. - pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> { - let rule = match key.chars().filter(|c| *c != '_').collect::().to_ascii_lowercase().as_str() { - "ruleabove" => ¤t.rule_above, - "rulebelow" => ¤t.rule_below, - "tabs" if !value.is_null() => { - // Count before deserializing: the list may be arbitrarily long. - if value.as_array().is_some_and(|a| a.len() > MAX_TAB_STOPS) { - return Err(format!("{key}: a paragraph holds at most {MAX_TAB_STOPS} tab stops")); - } - let tabs: Vec = serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?; - self.tabs = Some(TabStop::checked_list(tabs).map_err(|e| format!("{key}: {e}"))?); - return Ok(()); - } - _ => return self.set_json(key, value), - }; - let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; - let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; - let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; - for (k, v) in patch { - if !fields.contains_key(k) { - return Err(format!("{key}: unknown rule field `{k}`")); - } - fields.insert(k.clone(), v.clone()); - } - self.set_json(key, &merged) - } -} - -impl ParaProps { - /// The label of a numbered paragraph with number `n`. - pub fn number_label(&self, n: u32) -> String { - expand_list_text(&self.number_expression, &self.number_style.format(n)) - } - - /// The label of a numbered paragraph that `list` has just numbered ([`ListCounter::advance`]). - pub fn number_label_in(&self, list: &ListCounter) -> String { - let level = list_level_index(self.list_level); - let number = list.labels.get(level).map_or("", String::as_str); - expand_list_levels(&self.number_expression, number, &list.labels) - } - - /// The label of a bulleted paragraph. - pub fn bullet_label(&self) -> String { - let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); - s.push_str(&expand_list_text(&self.list_separator, "")); - s - } -} - -/// Levels a list can have. -pub const LIST_LEVELS: usize = 9; - -/// Index (0-based) of list level `level` (1-based, clamped to 1–[`LIST_LEVELS`]). -fn list_level_index(level: u32) -> usize { - (level.clamp(1, LIST_LEVELS as u32) - 1) as usize -} - -/// The numbering of one list as it goes: each level's last number and its text, and when each -/// level last numbered a paragraph (for Restart Numbers at This Level After). -#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] -pub struct ListCounter { - numbers: [u32; LIST_LEVELS], - labels: [String; LIST_LEVELS], - seen: [u64; LIST_LEVELS], - tick: u64, -} - -impl ListCounter { - /// Number the next paragraph of `pp` (a numbered paragraph with text) and return its number. - pub fn advance(&mut self, pp: &ParaProps) -> u32 { - let l = list_level_index(pp.list_level); - self.tick = self.tick.saturating_add(1); - let since = self.seen[l]; - let restart = pp.restart_numbers - && match pp.restart_after_level { - 0 => self.seen[..l].iter().any(|&t| t > since), - m => (m as usize) <= l && self.seen.get(m as usize - 1).is_some_and(|&t| t > since), - }; - let n = match pp.start_at { - Some(s) => s.max(1), - None if restart => 1, - None => self.numbers[l].saturating_add(1), - }; - self.numbers[l] = n; - self.labels[l] = pp.number_style.format(n); - self.seen[l] = self.tick; - n - } -} - #[cfg(test)] -mod tests { - use super::*; - - /// #777: an unknown attribute names the nearest one, or lists them all. - #[test] - fn unknown_attributes_suggest_the_right_name() { - let mut c = CharAttrs::default(); - assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?"); - assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?"); - let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err(); - assert!( - all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), - "{all}" - ); - assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok()); - let mut p = ParaAttrs::default(); - assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?"); - assert_eq!(camel("first_line_indent"), "firstLineIndent"); - } - - #[test] - fn levels_number_on_their_own_and_restart_after_a_higher_level() { - let at = - |level: u32| ParaProps { list_type: ListType::Numbers, list_level: level, number_expression: "^1.^#".into(), ..ParaProps::default() }; - let mut c = ListCounter::default(); - let mut labels = vec![]; - for level in [1, 2, 2, 1, 2] { - let pp = at(level); - c.advance(&pp); - labels.push(pp.number_label_in(&c)); - } - assert_eq!(labels, ["1.1", "1.1", "1.2", "2.2", "2.1"]); - // Without the restart, level 2 carries on. - let mut c = ListCounter::default(); - let keep = |level: u32| ParaProps { restart_numbers: false, ..at(level) }; - let n: Vec = [1, 2, 1, 2].into_iter().map(|l| c.advance(&keep(l))).collect(); - assert_eq!(n, [1, 1, 2, 2]); - } - - #[test] - fn a_partial_rule_keeps_the_other_fields() { - let current = ParaProps { - rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() }, - ..ParaProps::default() - }; - let mut a = ParaAttrs::default(); - a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap(); - assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() })); - assert_eq!(a.rule_above, None); - assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field"); - a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap(); - assert_eq!(a.rule_below, None, "null clears the override"); - } - - #[test] - fn stroke_behind_fill_is_saved_only_when_set() { - assert!(!CharProps::default().stroke_behind_fill); - assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); - let mut a = CharAttrs::default(); - a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); - let v = serde_json::to_value(&a).unwrap(); - assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); - assert_eq!(serde_json::from_value::(v).unwrap(), a); - let mut p = CharProps::default(); - p.apply(&a); - assert!(p.stroke_behind_fill); - } - - #[test] - fn digits_map_to_their_script() { - assert_eq!(Digits::Default.map('7', "Arabic"), '7'); - assert_eq!(Digits::Hindi.map('3', ""), '\u{0663}'); - assert_eq!(Digits::Farsi.map('0', ""), '\u{06F0}'); - assert_eq!(Digits::Native.map('5', "Thai"), '\u{0E55}'); - assert_eq!(Digits::Native.map('5', "English: USA"), '5'); - assert_eq!(Digits::Hindi.map('x', ""), 'x'); - } - - #[test] - fn language_names_map_to_bcp47_tags() { - assert_eq!(language_tag("English: USA"), Some("en-US")); - assert_eq!(language_tag("English: Australian"), Some("en")); - assert_eq!(language_tag("Turkish"), Some("tr")); - assert_eq!(language_tag("Georgian"), Some("ka")); - assert_eq!(language_tag("Japanese"), Some("ja")); - assert_eq!(language_tag("Korean"), Some("ko")); - assert_eq!(language_tag("Chinese"), Some("zh")); - assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans")); - assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant")); - assert_eq!(language_tag("Arabic"), Some("ar")); - assert_eq!(language_tag("Persian"), Some("fa")); - assert_eq!(language_tag("Farsi"), Some("fa")); - assert_eq!(language_tag("Urdu"), Some("ur")); - assert_eq!(language_tag("Hebrew"), Some("he")); - assert_eq!(language_tag("[No Language]"), None); - assert_eq!(language_tag("Klingon"), None); - assert_eq!(language_tag(""), None); - } - - #[test] - fn chinese_japanese_and_korean_names_map_in_their_common_spellings() { - for (name, tag) in [ - ("Chinese: Simplified", Some("zh-Hans")), - ("Simplified Chinese", Some("zh-Hans")), - ("Chinese Simplified", Some("zh-Hans")), - ("Chinese (Simplified)", Some("zh-Hans")), - (" chinese: SIMPLIFIED ", Some("zh-Hans")), - ("zh_CN", Some("zh-Hans")), - ("zh-CN", Some("zh-Hans")), - ("zh-Hans", Some("zh-Hans")), - ("zh-Hans-CN", Some("zh-Hans")), - ("Chinese: Traditional", Some("zh-Hant")), - ("Traditional Chinese", Some("zh-Hant")), - ("Chinese Traditional", Some("zh-Hant")), - ("Chinese (Traditional)", Some("zh-Hant")), - ("zh_TW", Some("zh-Hant")), - ("zh-TW", Some("zh-Hant")), - ("zh_HK", Some("zh-Hant")), - ("zh-HK", Some("zh-Hant")), - ("zh-Hant", Some("zh-Hant")), - ("Chinese", Some("zh")), - ("zh", Some("zh")), - ("Japanese", Some("ja")), - ("ja", Some("ja")), - ("ja_JP", Some("ja-JP")), - ("ja-JP", Some("ja-JP")), - ("Korean", Some("ko")), - ("ko_KR", Some("ko-KR")), - ("ko-KR", Some("ko-KR")), - ("English: USA", Some("en-US")), - ("German: Swiss 2006 Reform", Some("de-CH")), - ("Norwegian: Bokmål", Some("nb")), - ("[No Language]", None), - ] { - assert_eq!(language_tag(name), tag, "{name}"); - } - } - - #[test] - fn locale_codes_map_to_their_tags() { - for (name, tag) in [ - // As InDesign-exported IDML writes them (AppliedLanguage). - ("$ID/de_DE_2006", Some("de-DE")), - ("$ID/nl_NL_2005", Some("nl-NL")), - ("$ID/English: USA", Some("en-US")), - ("$ID/English: UK", Some("en-GB")), - ("$ID/Spanish: Castilian", Some("es-ES")), - ("$ID/French", Some("fr")), - // As IDML import keeps them. - ("de_DE_2006", Some("de-DE")), - ("nl_NL_2005", Some("nl-NL")), - ("Spanish: Castilian", Some("es-ES")), - ("French", Some("fr")), - // Other locale codes. - ("de-DE", Some("de-DE")), - ("de_CH_2006", Some("de-CH")), - ("pt_BR", Some("pt-BR")), - ("en_us", Some("en-US")), - ("es_419", Some("es-419")), - ("fil_PH", Some("fil-PH")), - ("sv", Some("sv")), - ("ja", Some("ja")), - // Not codes. - ("de_DE_2006_x", None), - ("d_DE", None), - ("de_D", None), - ("de_DE_06", None), - ("1e_DE", None), - ("[No Language]", None), - ] { - assert_eq!(language_tag(name), tag, "{name}"); - } - assert_eq!(language_subtag("de-DE"), "de"); - assert_eq!(language_subtag("zh-Hant"), "zh"); - assert_eq!(language_subtag("ko"), "ko"); - } - - #[test] - fn merge_and_resolve() { - let mut a = CharAttrs { size: Some(10.0), ..Default::default() }; - a.merge(&CharAttrs { tracking: Some(20.0), size: Some(11.0), ..Default::default() }); - assert_eq!(a.size, Some(11.0)); - assert_eq!(a.count(), 2); - let mut p = CharProps::default(); - p.apply(&a); - assert_eq!(p.size, 11.0); - assert_eq!(p.tracking, 20.0); - assert_eq!(p.font_family, "Source Serif 4"); - } - - #[test] - fn tab_lists_are_checked() { - let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; - assert!(TabStop::checked_list(vec![stop(f64::NAN)]).is_err()); - assert!(TabStop::checked_list(vec![stop(f64::INFINITY)]).is_err()); - assert!(TabStop::checked_list((0..=MAX_TAB_STOPS).map(|i| stop(i as f64)).collect()).is_err()); - let list = TabStop::checked_list(vec![stop(50.0), TabStop { leader: ".".into(), ..stop(10.0) }, stop(50.001)]).unwrap(); - assert_eq!(list.iter().map(|t| t.position).collect::>(), [10.0, 50.001], "sorted, one stop per position"); - } - - /// A file's tab list is repaired, never refused; a list the commands accept comes through - /// unchanged. - #[test] - fn tab_lists_from_files_are_sanitized() { - let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; - let mut tabs: Vec = (0..500).rev().map(|i| stop(i as f64 * 10.0)).collect(); - tabs.push(stop(f64::NAN)); - tabs.push(stop(f64::NEG_INFINITY)); - tabs.push(TabStop { leader: "\u{7}0123456789".into(), align: TabAlign::Char, align_on: "\n,;".into(), ..stop(-3.0) }); - let list = TabStop::sanitized_list(tabs); - assert_eq!(list.len(), MAX_TAB_STOPS); - assert!(list.windows(2).all(|w| w[1].position - w[0].position >= SAME_TAB_POSITION), "sorted, one stop per position"); - let first = &list[0]; - assert_eq!((first.position, first.leader.as_str(), first.align_on.as_str()), (0.0, "01234567", ","), "the later stop at 0 wins"); - assert_eq!(list.last().map(|t| t.position), Some(990.0), "the first stops by position are kept"); - - let ok = TabStop::checked_list(vec![stop(72.0), TabStop { align: TabAlign::Char, ..stop(10.0) }]).unwrap(); - assert_eq!(TabStop::sanitized_list(ok.clone()), ok); - - // `null` is how JSON writes a non-finite position; that stop is dropped, the others kept. - let json = r#"{"tabs":[{"position":null},{"position":20000,"leader":"0123456789"},{"position":5}]}"#; - let a: ParaAttrs = serde_json::from_str(json).unwrap(); - let tabs = a.tabs.unwrap(); - assert_eq!(tabs.iter().map(|t| t.position).collect::>(), [5.0, MAX_TAB_POSITION]); - assert_eq!(tabs[1].leader, "01234567"); - let p: ParaProps = serde_json::from_str(json).unwrap(); - assert_eq!(p.tabs, tabs); - assert!(serde_json::from_str::("{}").is_err(), "a stop names its position"); - } - - #[test] - fn set_json_by_name() { - let mut a = ParaAttrs::default(); - a.set_json("spaceBefore", &serde_json::json!(6.0)).unwrap(); - a.set_json("align", &serde_json::json!("center")).unwrap(); - a.set_json("left_indent", &serde_json::json!(12)).unwrap(); - assert_eq!(a.space_before, Some(6.0)); - assert_eq!(a.align, Some(Align::Center)); - assert_eq!(a.left_indent, Some(12.0)); - assert!(a.set_json("nope", &serde_json::json!(1)).is_err()); - a.set_json("align", &serde_json::Value::Null).unwrap(); - assert_eq!(a.align, None); - } - - #[test] - fn delta_and_common() { - let a = CharProps::default(); - let mut b = a.clone(); - b.size = 18.0; - let d = a.delta(&b); - assert_eq!(d.count(), 1); - let c = CharProps::common([&a, &b]); - assert_eq!(c.size, None); - assert_eq!(c.font_family.as_deref(), Some("Source Serif 4")); - } - - #[test] - fn serde_skips_unset() { - let a = CharAttrs { size: Some(9.0), ..Default::default() }; - assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"size":9.0}"#); - let p: ParaProps = serde_json::from_str(r#"{"align":"center"}"#).unwrap(); - assert_eq!(p.align, Align::Center); - assert_eq!(p.word_space_max, 1.33); - } - - #[test] - fn same_para_style_spacing_ignore_survives_serde() { - for v in [None, Some(None), Some(Some(0.0))] { - let a = ParaAttrs { same_para_style_spacing: v, ..Default::default() }; - let back: ParaAttrs = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); - assert_eq!(back.same_para_style_spacing, v); - } - } - - #[test] - fn numbering_styles() { - assert_eq!(NumberStyle::UpperRoman.format(14), "XIV"); - assert_eq!(NumberStyle::LowerRoman.format(9), "ix"); - assert_eq!(NumberStyle::UpperLetters.format(1), "A"); - assert_eq!(NumberStyle::UpperLetters.format(27), "AA"); - assert_eq!(NumberStyle::LowerLetters.format(28), "bb"); - assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03"); - assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007"); - assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012"); - // Repeating forms stay small for huge numbers. - assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string()); - assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string()); - assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string()); - } - - #[test] - fn kanji_numbers_are_written_digit_by_digit() { - for (n, s) in [ - (0, "〇"), - (1, "一"), - (5, "五"), - (10, "一〇"), - (11, "一一"), - (20, "二〇"), - (38, "三八"), - (99, "九九"), - (100, "一〇〇"), - (105, "一〇五"), - (1000, "一〇〇〇"), - (2026, "二〇二六"), - (u32::MAX, "四二九四九六七二九五"), - ] { - assert_eq!(NumberStyle::Kanji.format(n), s, "{n}"); - } - } - - #[test] - fn list_labels_expand_metacharacters() { - let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() }; - assert_eq!(p.number_label(2), "B.\t"); - let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() }; - assert_eq!(p.number_label(3), "Tabel 3\t"); - assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^"); - assert_eq!(expand_list_text("a\nb^p", "1"), "ab"); - assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t"); - } -} +mod tests; diff --git a/crates/doc/src/attrs/char_attrs.rs b/crates/doc/src/attrs/char_attrs.rs new file mode 100644 index 000000000..4f4cb9d52 --- /dev/null +++ b/crates/doc/src/attrs/char_attrs.rs @@ -0,0 +1,177 @@ +use super::{ + Capitalization, ChangeMark, Deserialize, Digits, Kerning, Leading, Position, Serialize, attr_key, deserialize_explicit_value, unknown_attribute, +}; + +attr_set! { + /// Character attributes (Character panel / character styles / the character part of paragraph styles). + CharAttrs / CharProps { + font_family: String = "Source Serif 4".into(), + font_style: String = "Regular".into(), + /// Point size. + size: f64 = 12.0, + leading: Leading = Leading::Auto, + kerning: Kerning = Kerning::Metrics, + /// Tracking in 1/1000 em. + tracking: f64 = 0.0, + /// Explicit CJK aki, in em; None restores automatic spacing. + leading_aki: Option = None => "deserialize_explicit_value", + trailing_aki: Option = None => "deserialize_explicit_value", + /// Proportional character compression, 0..1. + tsume: f64 = 0.0, + /// Target em cells for the contiguous formatted group (0 disables jidori). + jidori: u32 = 0, + character_alignment: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, + leading_model: crate::cjk::LeadingModel = crate::cjk::LeadingModel::Roman, + /// The kenten mark's text: a custom mark, or the preset of `kenten_kind` (see + /// [`crate::cjk_settings::kenten_mark`]); empty is the sesame dot. + kenten_character: String = String::new(), + /// Horizontal / vertical scale, 1.0 = 100%. + h_scale: f64 = 1.0, + v_scale: f64 = 1.0, + baseline_shift: f64 = 0.0, + /// Skew (false italic), degrees. + skew: f64 = 0.0, + fill: String = "[Black]".into(), + fill_tint: f32 = 1.0, + stroke: String = "[None]".into(), + stroke_tint: f32 = 1.0, + stroke_weight: f64 = 1.0, + /// Stroke behind fill: the stroke is painted under the fill and lies wholly outside the + /// letters at its full weight, so the letters keep their shape. Off, it straddles the outline. + stroke_behind_fill: bool = false, + capitalization: Capitalization = Capitalization::Normal, + position: Position = Position::Normal, + underline: bool = false, + strikethrough: bool = false, + /// Underline Options: weight and offset below the baseline (`None` = from the size), + /// colour (empty = the text colour) and tint. + underline_weight: Option = None => "deserialize_explicit_value", + underline_offset: Option = None => "deserialize_explicit_value", + underline_color: String = String::new(), + underline_tint: f32 = 1.0, + /// Strikethrough Options: offset above the baseline. + strikethrough_weight: Option = None => "deserialize_explicit_value", + strikethrough_offset: Option = None => "deserialize_explicit_value", + strikethrough_color: String = String::new(), + strikethrough_tint: f32 = 1.0, + glyph_form: String = String::new(), + ligatures: bool = true, + no_break: bool = false, + /// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line. + tate_chu_yoko: bool = false, + tate_chu_yoko_x_offset: f64 = 0.0, + tate_chu_yoko_y_offset: f64 = 0.0, + /// Ruby: the reading set small above the text (to its right in vertical text). Group ruby + /// sets it over the whole run; per-character ruby holds one reading per character, + /// separated by U+3000. Its other settings are the `ruby_*` fields below. + ruby: String = String::new(), + /// Kenten: an emphasis dot above each character. + kenten: bool = false, + /// Warichu: the run is set in smaller lines stacked inside one line height. + warichu: bool = false, + /// How many warichu lines (InDesign's minimum is 2). + warichu_lines: u32 = 2, + /// Warichu size as a percentage of the parent size. + warichu_size: f64 = 50.0, + /// Extra points between warichu baselines. 0 is automatic (one small em). + warichu_line_spacing: f64 = 0.0, + warichu_alignment: crate::cjk::WarichuAlignment = crate::cjk::WarichuAlignment::Auto, + /// Minimum characters on a warichu line before a break, and on the line after it. + warichu_chars_before_break: u32 = 1, + warichu_chars_after_break: u32 = 1, + // Kenten Settings / Kenten Color (`kenten` switches the marks on). + /// The mark; [`crate::cjk_settings::KentenKind::Custom`] draws `kenten_character`. + kenten_kind: crate::cjk_settings::KentenKind = crate::cjk_settings::KentenKind::SesameDot, + /// Font of a custom mark (empty = the text's font) and its style. + kenten_font: String = String::new(), + kenten_font_style: String = String::new(), + /// Mark size in points; `None` is half the text size. + kenten_size: Option = None, + /// Horizontal / vertical scale of the mark, 1.0 = 100%. + kenten_x_scale: f64 = 1.0, + kenten_y_scale: f64 = 1.0, + /// Points between the text's em box and the mark's. + kenten_distance: f64 = 0.0, + kenten_position: crate::cjk_settings::KentenPosition = crate::cjk_settings::KentenPosition::AboveRight, + kenten_alignment: crate::cjk_settings::KentenAlignment = crate::cjk_settings::KentenAlignment::Center, + /// How a custom mark was entered (IDML `KentenCharacterSet`), kept for interchange. + kenten_character_set: String = String::new(), + /// Mark fill and stroke swatches (empty = the text's), tints and stroke weight (`None` = + /// the text's). + kenten_fill: String = String::new(), + kenten_fill_tint: Option = None, + kenten_stroke: String = String::new(), + kenten_stroke_tint: Option = None, + kenten_stroke_weight: Option = None, + kenten_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + kenten_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + // Shatai (斜体): the character is compressed across the direction at `shatai_angle`. + /// Compression in percent of the em (0 = off). + shatai_magnification: f64 = 0.0, + /// Degrees from the line direction, counter-clockwise. + shatai_angle: f64 = 45.0, + /// Turn the compressed character back so its edge along the line stays along the line. + shatai_adjust_rotation: bool = false, + /// Fit the advance to the compressed em box. + shatai_adjust_tsume: bool = true, + // Ruby settings (see [`crate::ruby`]). Defaults are the IDML text defaults. + ruby_type: crate::ruby::RubyType = crate::ruby::RubyType::Group, + /// How a ruby shorter than its parent is spread over it. + ruby_alignment: crate::ruby::RubyAlignment = crate::ruby::RubyAlignment::Jis, + ruby_position: crate::ruby::RubyPosition = crate::ruby::RubyPosition::AboveRight, + /// Points along the line, and away from the parent text across it. + ruby_x_offset: f64 = 0.0, + ruby_y_offset: f64 = 0.0, + /// Empty: the parent's font and style. + ruby_font: String = String::new(), + ruby_font_style: String = String::new(), + /// Points; `None` is half the parent size. + ruby_font_size: Option = None, + /// 1.0 = 100%. + ruby_x_scale: f64 = 1.0, + ruby_y_scale: f64 = 1.0, + /// Use the font's ruby glyphs (OpenType `ruby`). + ruby_open_type_pro: bool = true, + /// Tate-chu-yoko in vertical ruby for runs of up to this many digits (0 = off). + ruby_auto_tcy_digits: u32 = 0, + ruby_auto_tcy_include_roman: bool = false, + ruby_auto_tcy_auto_scale: bool = true, + /// IDML `RubyOverhang`, kept for round trips; `ruby_overhang_amount` sets the overhang. + ruby_overhang: bool = false, + ruby_overhang_amount: crate::ruby::RubyOverhang = crate::ruby::RubyOverhang::OneRuby, + ruby_parent_spacing: crate::ruby::RubyParentSpacing = crate::ruby::RubyParentSpacing::Aki121, + /// A longer ruby at a line's start or end is set flush with the line edge. + ruby_auto_align: bool = true, + /// A longer ruby is narrowed first, down to `ruby_scaling_min` (1.0 = 100%). + ruby_auto_scaling: bool = false, + ruby_scaling_min: f64 = 0.66, + /// Ruby colour: empty is the text's colour; `None` tint and weight follow the text. + ruby_fill: String = String::new(), + ruby_fill_tint: Option = None, + ruby_stroke: String = String::new(), + ruby_stroke_tint: Option = None, + ruby_stroke_weight: Option = None, + ruby_overprint_fill: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + ruby_overprint_stroke: crate::cjk_settings::AdornmentOverprint = crate::cjk_settings::AdornmentOverprint::Auto, + /// Digits (World-Ready): how 0–9 are drawn. + digits: Digits = Digits::Default, + character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default, + /// Character-level permission, additionally gated by paragraph kashidas. + allow_kashidas: bool = true, + diacritic_position: crate::arabic::DiacriticPosition = crate::arabic::DiacriticPosition::Default, + /// Additional mark offsets, in thousandths of an em. Positive y is up. + diacritic_x_offset: f64 = 0.0, + diacritic_y_offset: f64 = 0.0, + /// IDML contextual-form request, separate from general OpenType features. + positional_form: String = String::new(), + language: String = "English: USA".into(), + /// Additional OpenType features, e.g. `["onum", "ss01"]`. + otf_features: Vec = Vec::new(), + /// Conditional text: the conditions applied (Window › Type & Tables › Conditional Text). + conditions: Vec = Vec::new(), + /// Track Changes: text added or deleted while tracking (deleted text isn't laid out). + change: ChangeMark = ChangeMark::None, + /// XML: the element this text is tagged with (Tags panel, inline). + xml_tag: String = String::new(), + } +} diff --git a/crates/doc/src/attrs/enums.rs b/crates/doc/src/attrs/enums.rs new file mode 100644 index 000000000..e655c7658 --- /dev/null +++ b/crates/doc/src/attrs/enums.rs @@ -0,0 +1,310 @@ +use super::{Deserialize, Serialize}; + +// ---------- value types ---------- + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum Leading { + /// Auto leading (percentage of the largest size on the line, Justification → Auto Leading). + #[default] + Auto, + Points(f64), +} + +/// Paragraph direction. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextDirection { + #[default] + LeftToRight, + RightToLeft, +} + +/// Digits (World-Ready): the figures 0–9 are drawn as these. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Digits { + /// Contextual digits; composition selects the preceding strong script/language. + #[default] + Default, + /// European (0123). + Arabic, + /// Arabic-Indic (٠١٢٣). + Hindi, + /// Extended Arabic-Indic, as used for Persian and Urdu (۰۱۲۳). + Farsi, + /// Those of the text's language (Thai, Devanagari, Bengali, Arabic, Persian…); else as typed. + Native, +} + +impl Digits { + /// The digit `c` (0–9) is drawn as, for text in `language`. + pub fn map(self, c: char, language: &str) -> char { + let d = match c { + '0'..='9' => c as u32 - '0' as u32, + '\u{0660}'..='\u{0669}' => c as u32 - 0x0660, + '\u{06F0}'..='\u{06F9}' => c as u32 - 0x06F0, + _ => return c, + }; + let zero = match self { + Digits::Default => return c, + Digits::Arabic => 0x0030, + Digits::Hindi => 0x0660, + Digits::Farsi => 0x06F0, + Digits::Native => { + let l = language.to_ascii_lowercase(); + match () { + _ if l.starts_with("arabic") || l == "ar" || l.starts_with("ar-") => 0x0660, + _ if l.starts_with("persian") + || l.starts_with("farsi") + || l.starts_with("urdu") + || l == "fa" + || l.starts_with("fa-") + || l == "ur" + || l.starts_with("ur-") => + { + 0x06F0 + } + _ if l.starts_with("hindi") || l.starts_with("marathi") || l.starts_with("nepali") || l.starts_with("sanskrit") => 0x0966, + _ if l.starts_with("bengali") => 0x09E6, + _ if l.starts_with("gujarati") => 0x0AE6, + _ if l.starts_with("punjabi") => 0x0A66, + _ if l.starts_with("tamil") => 0x0BE6, + _ if l.starts_with("telugu") => 0x0C66, + _ if l.starts_with("kannada") => 0x0CE6, + _ if l.starts_with("malayalam") => 0x0D66, + _ if l.starts_with("thai") => 0x0E50, + _ if l.starts_with("lao") => 0x0ED0, + _ if l.starts_with("tibetan") => 0x0F20, + _ if l.starts_with("burmese") => 0x1040, + _ if l.starts_with("khmer") => 0x17E0, + _ => return c, + } + } + }; + char::from_u32(zero + d).unwrap_or(c) + } +} + +/// A tracked change on text. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeMark { + #[default] + None, + Inserted, + Deleted, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "value")] +pub enum Kerning { + #[default] + Metrics, + Optical, + /// Zero ("0" in the kerning menu). + None, + /// Manual pair kerning in 1/1000 em (applies after this character). + Manual(f64), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Capitalization { + #[default] + Normal, + AllCaps, + SmallCaps, + OpenTypeAllSmallCaps, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Position { + #[default] + Normal, + Superscript, + Subscript, + OtSuperscript, + OtSubscript, + OtNumerator, + OtDenominator, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Align { + #[default] + Left, + Center, + Right, + LeftJustified, + CenterJustified, + RightJustified, + FullyJustified, + TowardsSpine, + AwayFromSpine, +} + +impl Align { + pub fn is_justified(self) -> bool { + matches!(self, Align::LeftJustified | Align::CenterJustified | Align::RightJustified | Align::FullyJustified) + } + pub const ALL: [Align; 9] = [ + Align::Left, + Align::Center, + Align::Right, + Align::LeftJustified, + Align::CenterJustified, + Align::RightJustified, + Align::FullyJustified, + Align::TowardsSpine, + Align::AwayFromSpine, + ]; + pub fn label(self) -> &'static str { + match self { + Align::Left => "Align left", + Align::Center => "Align center", + Align::Right => "Align right", + Align::LeftJustified => "Justify with last line aligned left", + Align::CenterJustified => "Justify with last line aligned center", + Align::RightJustified => "Justify with last line aligned right", + Align::FullyJustified => "Justify all lines", + Align::TowardsSpine => "Align towards spine", + Align::AwayFromSpine => "Align away from spine", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Composer { + /// Total-fit (Knuth–Plass) over the whole paragraph. + #[default] + Paragraph, + /// Greedy, line by line. + SingleLine, + /// The Japanese paragraph composer: total fit, with the CJK leading model and character + /// alignment. + Japanese, + /// The Japanese single-line composer. + JapaneseSingleLine, +} + +impl Composer { + /// Breaks lines greedily, one at a time. + pub fn single_line(self) -> bool { + matches!(self, Composer::SingleLine | Composer::JapaneseSingleLine) + } + + /// A Japanese composer: lines follow the leading model ([`crate::cjk::LeadingModel`]) and + /// characters their [`crate::cjk::CharacterAlignment`]. The others set lines baseline to + /// baseline with every character on the Roman baseline. + pub fn japanese(self) -> bool { + matches!(self, Composer::Japanese | Composer::JapaneseSingleLine) + } +} + +/// What a nested style runs to (Drop Caps and Nested Styles). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "chars")] +pub enum NestedUntil { + Sentences, + #[default] + Words, + Characters, + Letters, + Digits, + Tab, + ForcedLineBreak, + EmSpace, + EnSpace, + /// The paragraph's drop cap characters. + Dropcap, + /// Any of these characters. + Chars(String), +} + +/// A character style applied from the start of the paragraph through (or up to) the `count`-th +/// delimiter; nested styles follow each other. +/// A nested line style: `style` for the next `lines` lines. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NestedLineStyle { + pub style: String, + pub lines: u32, +} + +impl Default for NestedLineStyle { + fn default() -> Self { + NestedLineStyle { style: String::new(), lines: 1 } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NestedStyle { + pub style: String, + /// Through (include the delimiter) or up to (stop before it). + pub through: bool, + pub count: u32, + pub until: NestedUntil, +} + +impl Default for NestedStyle { + fn default() -> Self { + NestedStyle { style: String::new(), through: true, count: 1, until: NestedUntil::Words } + } +} + +impl NestedStyle { + /// The drop cap's character style as a nested style: `style` through 1 drop cap. + pub fn drop_cap(style: &str) -> Self { + NestedStyle { style: style.into(), through: true, count: 1, until: NestedUntil::Dropcap } + } + + /// Is this the form a drop cap's character style takes at the start of a nested style list? + pub fn is_drop_cap(&self) -> bool { + self.until == NestedUntil::Dropcap && self.through && self.count == 1 + } +} + +/// A character style applied to every match of a regular expression in the paragraph. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GrepStyle { + pub style: String, + pub pattern: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridAlign { + #[default] + None, + AllLines, + FirstLineOnly, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartParagraph { + #[default] + Anywhere, + NextColumn, + NextFrame, + NextPage, + NextOddPage, + NextEvenPage, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "columns")] +pub enum SpanColumns { + #[default] + Single, + /// Span `n` columns (0 = all). + Span(u32), + /// Split into `n` sub-columns. + Split(u32), +} diff --git a/crates/doc/src/attrs/language.rs b/crates/doc/src/attrs/language.rs new file mode 100644 index 000000000..35ac044a1 --- /dev/null +++ b/crates/doc/src/attrs/language.rs @@ -0,0 +1,134 @@ +/// Does `c` belong to a script that capitalizes the first letter of sentences, words and headings? +/// Georgian doesn't: Unicode maps Mkhedruli to Mtavruli capitals, but those are only for all-caps +/// text, never for a sentence's, a title's or an index heading's first letter. +pub fn capitalizes(c: char) -> bool { + !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') +} + +/// The BCP 47 tag of a language name as InDesign and IDML write it ("English: USA", "Chinese: +/// Simplified", or a locale code such as "de_DE_2006"), for the shaper's localized forms (`locl`) +/// and everything that depends on the language (quotes, hyphenation and spelling, line breaking, +/// fallback fonts). A variant we don't know falls back to its language ("English: Australian" → +/// `en`); an unknown language (or "[No Language]") has none. Case, punctuation and IDML's `$ID/` +/// don't matter. Locale codes (`ll`, `ll_CC`, `ll-CC`, with a reform year after them as in +/// `de_DE_2006`) are their tag (`de-DE`), and Chinese also goes by its other common spellings +/// ("Simplified Chinese", "Chinese (Traditional)", "zh_CN", "zh-Hant"). +pub fn language_tag(language: &str) -> Option<&'static str> { + let language = language.trim(); + let language = language.strip_prefix("$ID/").unwrap_or(language); + let l = language.to_lowercase(); + let words: Vec<&str> = l.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + let first = words.first().copied().unwrap_or(""); + let has = |any: &[&str]| words.iter().any(|w| any.contains(w)); + if first == "zh" || has(&["chinese"]) { + // The script decides; then the name; then the region. + return Some(if has(&["hans"]) { + "zh-Hans" + } else if has(&["hant"]) { + "zh-Hant" + } else if has(&["simplified", "cn", "sg"]) { + "zh-Hans" + } else if has(&["traditional", "tw", "hk", "mo"]) { + "zh-Hant" + } else { + "zh" + }); + } + if let Some(tag) = locale_code(&words) { + return intern_tag(tag); + } + let variant = match words.join(" ").as_str() { + "english usa" => Some("en-US"), + "english uk" => Some("en-GB"), + "english canadian" => Some("en-CA"), + "german swiss 2006 reform" | "german swiss" => Some("de-CH"), + "french swiss" => Some("fr-CH"), + "italian swiss" => Some("it-CH"), + "spanish castilian" => Some("es-ES"), + "french canadian" => Some("fr-CA"), + "portuguese brazilian" => Some("pt-BR"), + "norwegian bokmål" | "norwegian bokmal" => Some("nb"), + "norwegian nynorsk" => Some("nn"), + _ => None, + }; + if variant.is_some() { + return variant; + } + Some(match first { + "english" => "en", + "german" => "de", + "french" => "fr", + "spanish" => "es", + "catalan" => "ca", + "italian" => "it", + "portuguese" => "pt", + "dutch" => "nl", + "danish" => "da", + "swedish" => "sv", + "norwegian" => "no", + "finnish" => "fi", + "polish" => "pl", + "czech" => "cs", + "slovak" => "sk", + "hungarian" => "hu", + "romanian" => "ro", + "croatian" => "hr", + "lithuanian" => "lt", + "greek" => "el", + "russian" => "ru", + "ukrainian" => "uk", + "bulgarian" => "bg", + "serbian" => "sr", + "macedonian" => "mk", + "turkish" => "tr", + "japanese" => "ja", + "korean" => "ko", + "arabic" => "ar", + "persian" | "farsi" => "fa", + "urdu" => "ur", + "hebrew" => "he", + "georgian" => "ka", + _ => return None, + }) +} + +/// The tag of a locale code split into lowercase `words`: a language (2 or 3 letters), then +/// optionally a region (2 letters or 3 digits) and a reform year (4 digits) → `ll` or `ll-CC`. +fn locale_code(words: &[&str]) -> Option { + let letters = |w: &str, n: std::ops::RangeInclusive| n.contains(&w.len()) && w.bytes().all(|b| b.is_ascii_lowercase()); + let digits = |w: &str, n: usize| w.len() == n && w.bytes().all(|b| b.is_ascii_digit()); + match *words { + [lang] if letters(lang, 2..=3) => Some(lang.to_string()), + [lang, region] | [lang, region, _] if letters(lang, 2..=3) && (letters(region, 2..=2) || digits(region, 3)) => { + if let [_, _, year] = *words + && !digits(year, 4) + { + return None; + } + Some(format!("{lang}-{}", region.to_ascii_uppercase())) + } + _ => None, + } +} + +/// `tag` as a `&'static str`: each distinct tag is kept once for the process. Language names come +/// from documents, so at most [`MAX_TAGS`] are kept; past that a new tag is unknown (`None`). +fn intern_tag(tag: String) -> Option<&'static str> { + const MAX_TAGS: usize = 512; + static TAGS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + let mut tags = TAGS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(t) = tags.iter().find(|t| **t == tag) { + return Some(t); + } + if tags.len() >= MAX_TAGS { + return None; + } + let t: &'static str = Box::leak(tag.into_boxed_str()); + tags.push(t); + Some(t) +} + +/// The language subtag of a BCP 47 tag (`de-DE` → `de`, `zh-Hant` → `zh`). +pub fn language_subtag(tag: &str) -> &str { + tag.split('-').next().unwrap_or(tag) +} diff --git a/crates/doc/src/attrs/para_attrs.rs b/crates/doc/src/attrs/para_attrs.rs new file mode 100644 index 000000000..17edc8cc1 --- /dev/null +++ b/crates/doc/src/attrs/para_attrs.rs @@ -0,0 +1,254 @@ +use super::{ + Align, BoxBottom, BoxTop, BoxWidth, Composer, Deserialize, GrepStyle, GridAlign, ListAlign, ListCounter, ListType, MAX_TAB_STOPS, + NestedLineStyle, NestedStyle, NumberStyle, Rule, Serialize, SpanColumns, StartParagraph, TabStop, TextDirection, attr_key, + deserialize_explicit_value, expand_list_levels, expand_list_text, list_level_index, sanitized_tabs, unknown_attribute, +}; + +attr_set! { + /// Paragraph attributes (Paragraph panel / paragraph styles). + ParaAttrs / ParaProps { + align: Align = Align::Left, + /// Paragraph Direction (World-Ready): the base direction lines are ordered in. + direction: TextDirection = TextDirection::LeftToRight, + left_indent: f64 = 0.0, + right_indent: f64 = 0.0, + first_line_indent: f64 = 0.0, + last_line_indent: f64 = 0.0, + space_before: f64 = 0.0, + space_after: f64 = 0.0, + /// Space between consecutive paragraphs of the same style; replaces space after/before there (None = ignore). + same_para_style_spacing: Option = None => "deserialize_explicit_value", + /// Drop cap: the paragraph's first `drop_cap_chars` characters (code points, never + /// splitting a base from its combining marks) set `drop_cap_lines` lines tall, in the + /// character style `drop_cap_style` ("" or `[None]`: the text's own formatting). + drop_cap_lines: u32 = 0, + drop_cap_chars: u32 = 0, + drop_cap_style: String = String::new(), + /// Align Left Edge: the drop cap's ink starts at the indent (its first letter's left side + /// bearing is taken off), and the lines beside it start where its advance ends. + drop_cap_align_left: bool = true, + /// Scale for Descenders (kept and round-tripped; doesn't change the layout). + drop_cap_scale_descenders: bool = false, + /// Nested styles, in order from the paragraph start. + nested_styles: Vec = Vec::new(), + /// GREP styles: a character style for every match of a pattern (applied after nested). + grep_styles: Vec = Vec::new(), + /// Nested line styles: a character style for the paragraph's first lines, in order. + nested_line_styles: Vec = Vec::new(), + grid_align: GridAlign = GridAlign::None, + composer: Composer = Composer::Paragraph, + // Hyphenation + hyphenate: bool = true, + hyph_min_word: u32 = 5, + hyph_after_first: u32 = 2, + hyph_before_last: u32 = 2, + /// Max consecutive hyphenated lines (0 = unlimited). + hyph_limit: u32 = 3, + hyph_zone: f64 = 36.0, + hyph_capitalized: bool = true, + hyph_last_word: bool = true, + hyph_across_column: bool = true, + /// Better spacing (0) ↔ fewer hyphens (1). + hyph_weight: f64 = 0.5, + // Justification (percent as fractions) + word_space_min: f64 = 0.8, + word_space_desired: f64 = 1.0, + word_space_max: f64 = 1.33, + letter_space_min: f64 = 0.0, + letter_space_desired: f64 = 0.0, + letter_space_max: f64 = 0.0, + glyph_scale_min: f64 = 1.0, + glyph_scale_desired: f64 = 1.0, + glyph_scale_max: f64 = 1.0, + auto_leading: f64 = 1.2, + single_word_justify: Align = Align::FullyJustified, + /// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces. + kashidas: bool = true, + /// Preserved vendor policy; non-default policies require a dedicated composer. + arabic_justification: String = String::new(), + paragraph_kashida_width: Option = None => "deserialize_explicit_value", + /// Korean text breaks between any two syllables (character-based breaking); otherwise + /// it breaks at spaces, word by word. + korean_char_breaks: bool = false, + // Keeps + keep_with_next: u32 = 0, + keep_lines_together: bool = false, + /// All lines in paragraph (true) or at start/end of paragraph (false). + keep_all_lines: bool = false, + keep_first: u32 = 2, + keep_last: u32 = 2, + start_paragraph: StartParagraph = StartParagraph::Anywhere, + span_columns: SpanColumns = SpanColumns::Single, + /// Split Columns: the space between sub-columns and the inset from the column's edges. + split_inside_gutter: f64 = 6.0, + split_outside_gutter: f64 = 0.0, + rule_above: Rule = Rule::default(), + rule_below: Rule = Rule::default(), + #[serde(deserialize_with = "sanitized_tabs")] + tabs: Vec = Vec::new(), + list_type: ListType = ListType::None, + /// Named list (Define Lists) the numbers belong to ("" = the story's own list). + list_name: String = String::new(), + /// Start At: restart the numbering at this number. + start_at: Option = None => "deserialize_explicit_value", + bullet_char: String = "\u{2022}".into(), + number_style: NumberStyle = NumberStyle::Arabic, + /// A numbered paragraph's label: `^#` is the number, other metacharacters expand as in + /// [`expand_list_text`]. + number_expression: String = "^#.^t".into(), + /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). + list_separator: String = "\t".into(), + /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away + /// again with the list (an indent the paragraph had before stays). + list_hang: bool = false, + balance_ragged: bool = false, + /// Hang punctuation outside the measure in this paragraph, at each character's own size + /// (DesignCraft's own per-paragraph switch; a story with Optical Margin Alignment on + /// overrides it with the story's size). + optical_margin: bool = false, + /// Ignore Optical Margin: no hang in this paragraph, whatever the story or + /// `optical_margin` say. + ignore_optical_margin: bool = false, + /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. + kinsoku: Option = None => "deserialize_explicit_value", + /// Preserved spacing-table reference; compatibility audit reports unsupported composition. + mojikumi: String = String::new(), + /// Preserved priority policy; the current breaker does not implement these priorities. + kinsoku_type: String = String::new(), + kinsoku_hang: crate::cjk::KinsokuHang = crate::cjk::KinsokuHang::None, + bunri_kinshi: bool = false, + rensuuji: bool = true, + treat_ideographic_space_as_space: bool = false, + // Auto Tate-chu-yoko, Japanese Composition and Grid Settings. + /// Auto tate-chu-yoko: in vertical text, runs of up to this many half-width digits are set + /// across one em (0 = off). + auto_tcy: u32 = 0, + /// Auto tate-chu-yoko also takes runs of half-width roman letters. + auto_tcy_include_roman: bool = false, + /// Rotate Roman Characters in Vertical Text (縦組み中の欧文回転): half-width characters + /// stand upright one by one instead of lying along the line. + rotate_roman: bool = false, + /// Roman word break (欧文泣き別れ): roman words may break between any two letters, + /// without a hyphen. + roman_word_break: bool = false, + /// The point of a line that sits on the grid while `grid_align` is on. + grid_reference: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline, + /// Gyoudori (行取り): grid lines each line takes (0 = as many as its leading needs). + grid_gyoudori: u32 = 0, + /// Paragraph gyoudori: the paragraph as a whole, not each line, takes `grid_gyoudori` lines. + paragraph_gyoudori: bool = false, + /// Paragraph shading, and how far it reaches past the text: top, left, bottom, right. + shading_on: bool = false, + shading_color: String = "[Black]".into(), + shading_tint: f32 = 0.2, + shading_offsets: [f64; 4] = [0.0; 4], + /// Paragraph border: stroke weights (top, left, bottom, right), colour, and offsets past the + /// text (top, left, bottom, right). + border_on: bool = false, + border_weights: [f64; 4] = [1.0; 4], + border_color: String = "[Black]".into(), + border_tint: f32 = 1.0, + border_offsets: [f64; 4] = [0.0; 4], + /// The border's stroke type (a document stroke style by name, or a built-in type), cap and + /// join. + border_type: crate::item::StrokeType = crate::item::StrokeType::Solid, + border_cap: crate::item::Cap = crate::item::Cap::Butt, + border_join: crate::item::Join = crate::item::Join::Miter, + /// Corner shape and size: top left, top right, bottom right, bottom left. + border_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], + border_width: BoxWidth = BoxWidth::Column, + border_top: BoxTop = BoxTop::Ascent, + border_bottom: BoxBottom = BoxBottom::Descent, + /// A paragraph split across frames or columns gets a closed box in each part (otherwise the + /// box is open where it splits). + border_display_if_splits: bool = false, + /// Consecutive paragraphs with the same border and shading share one box. + border_merge: bool = true, + /// The colour between the dashes, dots or stripes of a border that isn't solid. + border_gap_color: String = "[None]".into(), + border_gap_tint: f32 = 1.0, + /// Shading corners: top left, top right, bottom right, bottom left. + shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], + shading_width: BoxWidth = BoxWidth::Column, + shading_top: BoxTop = BoxTop::Ascent, + shading_bottom: BoxBottom = BoxBottom::Descent, + /// The shading stops at the edges of the text frame. + shading_clip: bool = false, + /// The shading shows on screen only: not printed or exported. + shading_nonprinting: bool = false, + /// Least space between a spanning or split paragraph and the column text above and below it. + span_space_before: f64 = 0.0, + span_space_after: f64 = 0.0, + /// List level (1–9): each level numbers on its own, and `^1`…`^9` in a number's text are the + /// current numbers of those levels. + list_level: u32 = 1, + /// Character styles of the bullet and of the number. + bullet_char_style: String = crate::story::NO_CHAR_STYLE.into(), + number_char_style: String = crate::story::NO_CHAR_STYLE.into(), + bullet_align: ListAlign = ListAlign::Left, + number_align: ListAlign = ListAlign::Left, + /// A tab stop for the text after the bullet or number, from the column's left edge. + list_tab: Option = None, + /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise + /// that level) starts this level's numbering again. + restart_numbers: bool = true, + restart_after_level: u32 = 0, + /// The bullet's font family and style ("" = the paragraph's). + bullet_font: String = String::new(), + bullet_font_style: String = String::new(), + } +} + +impl ParaAttrs { + /// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the + /// rule fields to change; the others keep their values in `current` (the resolved attributes + /// of the paragraph or style being edited). A `tabs` list is checked and sorted as + /// [`TabStop::checked_list`] does. + pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> { + let rule = match key.chars().filter(|c| *c != '_').collect::().to_ascii_lowercase().as_str() { + "ruleabove" => ¤t.rule_above, + "rulebelow" => ¤t.rule_below, + "tabs" if !value.is_null() => { + // Count before deserializing: the list may be arbitrarily long. + if value.as_array().is_some_and(|a| a.len() > MAX_TAB_STOPS) { + return Err(format!("{key}: a paragraph holds at most {MAX_TAB_STOPS} tab stops")); + } + let tabs: Vec = serde_json::from_value(value.clone()).map_err(|e| format!("{key}: {e}"))?; + self.tabs = Some(TabStop::checked_list(tabs).map_err(|e| format!("{key}: {e}"))?); + return Ok(()); + } + _ => return self.set_json(key, value), + }; + let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; + let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; + let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + for (k, v) in patch { + if !fields.contains_key(k) { + return Err(format!("{key}: unknown rule field `{k}`")); + } + fields.insert(k.clone(), v.clone()); + } + self.set_json(key, &merged) + } +} + +impl ParaProps { + /// The label of a numbered paragraph with number `n`. + pub fn number_label(&self, n: u32) -> String { + expand_list_text(&self.number_expression, &self.number_style.format(n)) + } + + /// The label of a numbered paragraph that `list` has just numbered ([`ListCounter::advance`]). + pub fn number_label_in(&self, list: &ListCounter) -> String { + let level = list_level_index(self.list_level); + let number = list.labels.get(level).map_or("", String::as_str); + expand_list_levels(&self.number_expression, number, &list.labels) + } + + /// The label of a bulleted paragraph. + pub fn bullet_label(&self) -> String { + let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); + s.push_str(&expand_list_text(&self.list_separator, "")); + s + } +} diff --git a/crates/doc/src/attrs/rules_lists.rs b/crates/doc/src/attrs/rules_lists.rs new file mode 100644 index 000000000..f4ef952e4 --- /dev/null +++ b/crates/doc/src/attrs/rules_lists.rs @@ -0,0 +1,285 @@ +use super::{Deserialize, ParaProps, Serialize}; + +/// The colour of a paragraph rule that follows the paragraph's text colour. +pub const TEXT_COLOR: &str = "Text Color"; + +/// A paragraph rule (Rule Above / Rule Below). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Rule { + pub on: bool, + pub weight: f64, + /// A swatch, or [`TEXT_COLOR`]. + pub color: String, + pub tint: f32, + /// Width: column (true) or text (false). + pub column_width: bool, + pub offset: f64, + pub left_indent: f64, + pub right_indent: f64, +} + +impl Default for Rule { + fn default() -> Self { + Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + } +} + +/// How wide a paragraph border or shading box is: the column, or the text's longest line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxWidth { + #[default] + Column, + Text, +} + +/// Where a paragraph border or shading box starts above the first line (before its top offset). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxTop { + /// The tallest ascent on the line. + #[default] + Ascent, + Baseline, + /// The top of the em box. + EmBox, + /// The top of the line's leading. + Leading, +} + +/// Where a paragraph border or shading box ends below the last line (before its bottom offset). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxBottom { + /// The deepest descent on the line. + #[default] + Descent, + Baseline, + /// The bottom of the em box. + EmBox, +} + +/// Where a bullet or number sits against the first line's start (the left and first-line +/// indents): it starts there, is centred on it, or ends there. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListAlign { + #[default] + Left, + Center, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListType { + #[default] + None, + Bullets, + Numbers, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum NumberStyle { + #[default] + Arabic, + UpperRoman, + LowerRoman, + UpperLetters, + LowerLetters, + /// 01, 02, … + ArabicLeadingZero, + /// 001, 002, … + ArabicThreeDigits, + /// 0001, 0002, … + ArabicFourDigits, + /// *, †, ‡, §, ¶, ‖, then doubled (**, ††, …): footnotes. + Symbols, + /// 一, 二, 三, …: Chinese numerals written digit by digit, so 10 is 一〇 and 38 is 三八. + Kanji, +} + +/// Numbers past this are written in Arabic digits by the styles that repeat characters (roman +/// numerals, letters, symbols), so a hostile start number can't build a huge label. +const MAX_REPEATED_FORM: u32 = 3999; + +impl NumberStyle { + pub fn format(self, n: u32) -> String { + if n > MAX_REPEATED_FORM + && matches!( + self, + NumberStyle::UpperRoman | NumberStyle::LowerRoman | NumberStyle::UpperLetters | NumberStyle::LowerLetters | NumberStyle::Symbols + ) + { + return n.to_string(); + } + match self { + NumberStyle::Arabic => n.to_string(), + NumberStyle::ArabicLeadingZero => format!("{n:02}"), + NumberStyle::ArabicThreeDigits => format!("{n:03}"), + NumberStyle::ArabicFourDigits => format!("{n:04}"), + NumberStyle::UpperRoman => roman(n), + NumberStyle::LowerRoman => roman(n).to_lowercase(), + NumberStyle::UpperLetters => letters(n), + NumberStyle::LowerLetters => letters(n).to_lowercase(), + NumberStyle::Symbols => { + const SYM: [char; 6] = ['*', '†', '‡', '§', '¶', '‖']; + let n = n.max(1) as usize - 1; + std::iter::repeat_n(SYM[n % SYM.len()], n / SYM.len() + 1).collect() + } + NumberStyle::Kanji => kanji_digits(n), + } + } +} + +/// Expand the metacharacters of a list's label text (`^t` tab, `^>` en space, `^^` caret, …), +/// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no +/// meaning in a label are dropped; a label never holds a paragraph break. +pub fn expand_list_text(s: &str, number: &str) -> String { + expand_list_levels(s, number, &[]) +} + +/// [`expand_list_text`] with `levels[k]` in for `^1`…`^9` (level k + 1's current number; a level +/// past the end is dropped). +pub fn expand_list_levels(s: &str, number: &str, levels: &[String]) -> String { + let mut out = String::with_capacity(s.len() + number.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '^' { + if c != '\n' && c != '\r' { + out.push(c); + } + continue; + } + let Some(m) = it.next() else { + out.push('^'); + break; + }; + let r = match m { + '#' => { + out.push_str(number); + continue; + } + d @ '1'..='9' => { + let k = d as usize - '1' as usize; + if let Some(l) = levels.get(k) { + out.push_str(l); + } + continue; + } + '^' => '^', + 't' => '\t', + 'y' => crate::story::RIGHT_INDENT_TAB, + 'i' => crate::story::INDENT_HERE, + 'n' => crate::story::FORCED_LINE_BREAK, + 'm' => '\u{2003}', + '>' => '\u{2002}', + 'f' => '\u{2001}', + '|' => '\u{200A}', + '<' => '\u{2009}', + '/' => '\u{2007}', + '.' => '\u{2008}', + 's' => '\u{A0}', + 'S' => '\u{202F}', + '_' => '\u{2014}', + '=' => '\u{2013}', + '~' => '\u{2011}', + '{' => '\u{201C}', + '}' => '\u{201D}', + '[' => '\u{2018}', + ']' => '\u{2019}', + '"' => '"', + '\'' => '\'', + _ => continue, + }; + out.push(r); + } + out +} + +fn kanji_digits(n: u32) -> String { + const DIGITS: [char; 10] = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; + n.to_string().chars().map(|c| c.to_digit(10).and_then(|d| DIGITS.get(d as usize)).copied().unwrap_or(c)).collect() +} + +fn roman(mut n: u32) -> String { + if n == 0 { + return "0".into(); + } + let t = [ + (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(); + for (v, r) in t { + while n >= v { + s.push_str(r); + n -= v; + } + } + s +} + +/// A, B, …, Z, AA, BB, … (InDesign's letter numbering repeats the letter). +fn letters(n: u32) -> String { + if n == 0 { + return String::new(); + } + let i = (n - 1) % 26; + let reps = (n - 1) / 26 + 1; + std::iter::repeat_n(char::from(b'A' + i as u8), reps as usize).collect() +} + +/// Levels a list can have. +pub const LIST_LEVELS: usize = 9; + +/// Index (0-based) of list level `level` (1-based, clamped to 1–[`LIST_LEVELS`]). +pub(super) fn list_level_index(level: u32) -> usize { + (level.clamp(1, LIST_LEVELS as u32) - 1) as usize +} + +/// The numbering of one list as it goes: each level's last number and its text, and when each +/// level last numbered a paragraph (for Restart Numbers at This Level After). +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct ListCounter { + numbers: [u32; LIST_LEVELS], + pub(super) labels: [String; LIST_LEVELS], + seen: [u64; LIST_LEVELS], + tick: u64, +} + +impl ListCounter { + /// Number the next paragraph of `pp` (a numbered paragraph with text) and return its number. + pub fn advance(&mut self, pp: &ParaProps) -> u32 { + let l = list_level_index(pp.list_level); + self.tick = self.tick.saturating_add(1); + let since = self.seen[l]; + let restart = pp.restart_numbers + && match pp.restart_after_level { + 0 => self.seen[..l].iter().any(|&t| t > since), + m => (m as usize) <= l && self.seen.get(m as usize - 1).is_some_and(|&t| t > since), + }; + let n = match pp.start_at { + Some(s) => s.max(1), + None if restart => 1, + None => self.numbers[l].saturating_add(1), + }; + self.numbers[l] = n; + self.labels[l] = pp.number_style.format(n); + self.seen[l] = self.tick; + n + } +} diff --git a/crates/doc/src/attrs/tabs.rs b/crates/doc/src/attrs/tabs.rs new file mode 100644 index 000000000..b30299bd5 --- /dev/null +++ b/crates/doc/src/attrs/tabs.rs @@ -0,0 +1,181 @@ +use super::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TabAlign { + #[default] + Left, + Center, + Right, + /// Align on a character (default `.`). + Char, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabStop { + /// A file that held a non-finite position (written as `null`) reads it back as NaN, which + /// [`TabStop::sanitized_list`] drops. + #[serde(deserialize_with = "position_or_nan")] + pub position: f64, + #[serde(default)] + pub align: TabAlign, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub leader: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub align_on: String, +} + +/// Most stops a paragraph holds. +pub const MAX_TAB_STOPS: usize = 100; + +/// Furthest stop position: 216 in, the largest page. +pub const MAX_TAB_POSITION: f64 = 15_552.0; + +/// Longest leader, in characters. +pub const MAX_TAB_LEADER: usize = 8; + +/// Stops closer than this share a position. +pub const SAME_TAB_POSITION: f64 = 0.01; + +/// Smallest type size the engine accepts, in points. +pub const MIN_TYPE_SIZE: f64 = 0.1; + +/// Largest type size the engine accepts, in points, as InDesign's own field allows. +pub const MAX_TYPE_SIZE: f64 = 1296.0; + +/// Largest measure an imported indent, spacing or offset can carry, in points: the furthest tab +/// stop, which is the widest page. +pub const MAX_MEASURE: f64 = MAX_TAB_POSITION; + +/// A type size read from a file, or `None` when it is not a size the engine can mean. +/// +/// A file can hold any number, including `NaN`, `inf`, `0`, a negative or `1e300`. Every importer +/// had its own spelling of this test, or only checked that the value was finite -- so tagged text +/// took `` and set a 1e300 pt size on the text (#1593). A size outside the range is +/// dropped rather than clamped, so the text keeps the size its style gives it. +pub fn imported_type_size(pt: f64) -> Option { + (pt.is_finite() && (MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&pt)).then_some(pt) +} + +/// An indent, spacing or offset read from a file, clamped to ±[`MAX_MEASURE`]; `None` when the +/// value is not finite. +/// +/// Unlike a type size this is clamped rather than dropped: a file that says "indent everything" +/// still indents to the widest page the engine has, where dropping the value would quietly +/// restore the style's own indent and hide what the file asked for (#1593). +pub fn imported_measure(pt: f64) -> Option { + pt.is_finite().then(|| pt.clamp(-MAX_MEASURE, MAX_MEASURE)) +} + +impl TabStop { + /// A finite position clamped to `0..=MAX_TAB_POSITION`. + pub fn clamp_position(v: f64) -> Result { + if v.is_finite() { Ok(v.clamp(0.0, MAX_TAB_POSITION)) } else { Err("position must be a finite number".into()) } + } + + /// A leader: up to [`MAX_TAB_LEADER`] characters, no control characters. + pub fn check_leader(v: &str) -> Result<(), String> { + if v.chars().count() > MAX_TAB_LEADER || v.chars().any(char::is_control) { + return Err(format!("`leader`: at most {MAX_TAB_LEADER} printable characters")); + } + Ok(()) + } + + /// An align-on character: one printable character or none (a decimal point). + pub fn check_align_on(v: &str) -> Result<(), String> { + if v.chars().count() > 1 || v.chars().any(char::is_control) { + return Err("`alignOn`: one printable character".into()); + } + Ok(()) + } + + /// A tab-stop list as the Tabs commands keep it: at most [`MAX_TAB_STOPS`] stops, positions + /// clamped, leaders and align-on characters checked, a char stop aligning on `.` unless it + /// names a character, sorted by position, and one stop per position (the later one wins). + pub fn checked_list(mut tabs: Vec) -> Result, String> { + if tabs.len() > MAX_TAB_STOPS { + return Err(format!("a paragraph holds at most {MAX_TAB_STOPS} tab stops")); + } + for t in &mut tabs { + t.position = Self::clamp_position(t.position)?; + Self::check_leader(&t.leader)?; + Self::check_align_on(&t.align_on)?; + } + Ok(Self::normalized(tabs)) + } + + /// A tab-stop list read from a file, repaired to what [`checked_list`](Self::checked_list) + /// accepts: stops with a non-finite position dropped, positions clamped, control characters + /// removed from leaders and align-on characters and both cut to their limits, sorted, one stop + /// per position (the later one wins), and the first [`MAX_TAB_STOPS`] by position kept. + pub fn sanitized_list(tabs: Vec) -> Vec { + let tabs = tabs + .into_iter() + .filter_map(|mut t| { + t.position = Self::clamp_position(t.position).ok()?; + if Self::check_leader(&t.leader).is_err() { + t.leader = t.leader.chars().filter(|c| !c.is_control()).take(MAX_TAB_LEADER).collect(); + } + if Self::check_align_on(&t.align_on).is_err() { + t.align_on = t.align_on.chars().filter(|c| !c.is_control()).take(1).collect(); + } + Some(t) + }) + .collect(); + let mut out = Self::normalized(tabs); + out.truncate(MAX_TAB_STOPS); + out + } + + /// Finite, clamped stops sorted by position, one per position: of stops closer than + /// [`SAME_TAB_POSITION`], the later one in `tabs` wins. A char stop with no character aligns + /// on `.`. + fn normalized(tabs: Vec) -> Vec { + let mut stops: Vec<(usize, TabStop)> = tabs.into_iter().enumerate().collect(); + stops.sort_by(|a, b| a.1.position.total_cmp(&b.1.position)); + let mut out: Vec<(usize, TabStop)> = Vec::with_capacity(stops.len()); + for (i, mut t) in stops { + if t.align == TabAlign::Char && t.align_on.is_empty() { + t.align_on = ".".into(); + } + match out.last_mut() { + Some(last) if t.position - last.1.position < SAME_TAB_POSITION => { + if i > last.0 { + *last = (i, t); + } + } + _ => out.push((i, t)), + } + } + out.into_iter().map(|(_, t)| t).collect() + } +} + +/// A stop's position; `null` (how JSON writes a non-finite number) reads as NaN. +fn position_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) +} + +/// A paragraph's tab-stop list in either attribute form, as read from a file. +pub(super) trait FileTabs: Sized { + fn sanitized(self) -> Self; +} + +impl FileTabs for Vec { + fn sanitized(self) -> Self { + TabStop::sanitized_list(self) + } +} + +impl FileTabs for Option> { + fn sanitized(self) -> Self { + self.map(TabStop::sanitized_list) + } +} + +/// Deserializes a `tabs` field through [`TabStop::sanitized_list`]: a saved document's tab lists +/// get the limits the Tabs commands keep. +pub(super) fn sanitized_tabs<'de, D: serde::Deserializer<'de>, T: Deserialize<'de> + FileTabs>(d: D) -> Result { + T::deserialize(d).map(FileTabs::sanitized) +} diff --git a/crates/doc/src/attrs/tests.rs b/crates/doc/src/attrs/tests.rs new file mode 100644 index 000000000..068fbaee8 --- /dev/null +++ b/crates/doc/src/attrs/tests.rs @@ -0,0 +1,316 @@ +use super::*; + +/// #777: an unknown attribute names the nearest one, or lists them all. +#[test] +fn unknown_attributes_suggest_the_right_name() { + let mut c = CharAttrs::default(); + assert_eq!(c.set_json("font", &serde_json::json!("X")).unwrap_err(), "unknown attribute `font`; did you mean `fontFamily`?"); + assert_eq!(c.set_json("fontFamly", &serde_json::json!("X")).unwrap_err(), "unknown attribute `fontFamly`; did you mean `fontFamily`?"); + let all = c.set_json("zzzzzz", &serde_json::json!(1)).unwrap_err(); + assert!(all.starts_with("unknown attribute `zzzzzz`; the attributes are: ") && all.contains("fontFamily") && all.contains("fontStyle"), "{all}"); + assert!(c.set_json("font_family", &serde_json::json!("X")).is_ok() && c.set_json("fontFamily", &serde_json::json!("X")).is_ok()); + let mut p = ParaAttrs::default(); + assert_eq!(p.set_json("spaceBefor", &serde_json::json!(3)).unwrap_err(), "unknown attribute `spaceBefor`; did you mean `spaceBefore`?"); + assert_eq!(camel("first_line_indent"), "firstLineIndent"); +} + +#[test] +fn levels_number_on_their_own_and_restart_after_a_higher_level() { + let at = |level: u32| ParaProps { list_type: ListType::Numbers, list_level: level, number_expression: "^1.^#".into(), ..ParaProps::default() }; + let mut c = ListCounter::default(); + let mut labels = vec![]; + for level in [1, 2, 2, 1, 2] { + let pp = at(level); + c.advance(&pp); + labels.push(pp.number_label_in(&c)); + } + assert_eq!(labels, ["1.1", "1.1", "1.2", "2.2", "2.1"]); + // Without the restart, level 2 carries on. + let mut c = ListCounter::default(); + let keep = |level: u32| ParaProps { restart_numbers: false, ..at(level) }; + let n: Vec = [1, 2, 1, 2].into_iter().map(|l| c.advance(&keep(l))).collect(); + assert_eq!(n, [1, 1, 2, 2]); +} + +#[test] +fn a_partial_rule_keeps_the_other_fields() { + let current = ParaProps { + rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() }, + ..ParaProps::default() + }; + let mut a = ParaAttrs::default(); + a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap(); + assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() })); + assert_eq!(a.rule_above, None); + assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field"); + a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap(); + assert_eq!(a.rule_below, None, "null clears the override"); +} + +#[test] +fn stroke_behind_fill_is_saved_only_when_set() { + assert!(!CharProps::default().stroke_behind_fill); + assert_eq!(serde_json::to_value(CharAttrs::default()).unwrap(), serde_json::json!({})); + let mut a = CharAttrs::default(); + a.set_json("strokeBehindFill", &serde_json::json!(true)).unwrap(); + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v, serde_json::json!({"strokeBehindFill": true})); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + let mut p = CharProps::default(); + p.apply(&a); + assert!(p.stroke_behind_fill); +} + +#[test] +fn digits_map_to_their_script() { + assert_eq!(Digits::Default.map('7', "Arabic"), '7'); + assert_eq!(Digits::Hindi.map('3', ""), '\u{0663}'); + assert_eq!(Digits::Farsi.map('0', ""), '\u{06F0}'); + assert_eq!(Digits::Native.map('5', "Thai"), '\u{0E55}'); + assert_eq!(Digits::Native.map('5', "English: USA"), '5'); + assert_eq!(Digits::Hindi.map('x', ""), 'x'); +} + +#[test] +fn language_names_map_to_bcp47_tags() { + assert_eq!(language_tag("English: USA"), Some("en-US")); + assert_eq!(language_tag("English: Australian"), Some("en")); + assert_eq!(language_tag("Turkish"), Some("tr")); + assert_eq!(language_tag("Georgian"), Some("ka")); + assert_eq!(language_tag("Japanese"), Some("ja")); + assert_eq!(language_tag("Korean"), Some("ko")); + assert_eq!(language_tag("Chinese"), Some("zh")); + assert_eq!(language_tag("Chinese: Simplified"), Some("zh-Hans")); + assert_eq!(language_tag("Chinese: Traditional"), Some("zh-Hant")); + assert_eq!(language_tag("Arabic"), Some("ar")); + assert_eq!(language_tag("Persian"), Some("fa")); + assert_eq!(language_tag("Farsi"), Some("fa")); + assert_eq!(language_tag("Urdu"), Some("ur")); + assert_eq!(language_tag("Hebrew"), Some("he")); + assert_eq!(language_tag("[No Language]"), None); + assert_eq!(language_tag("Klingon"), None); + assert_eq!(language_tag(""), None); +} + +#[test] +fn chinese_japanese_and_korean_names_map_in_their_common_spellings() { + for (name, tag) in [ + ("Chinese: Simplified", Some("zh-Hans")), + ("Simplified Chinese", Some("zh-Hans")), + ("Chinese Simplified", Some("zh-Hans")), + ("Chinese (Simplified)", Some("zh-Hans")), + (" chinese: SIMPLIFIED ", Some("zh-Hans")), + ("zh_CN", Some("zh-Hans")), + ("zh-CN", Some("zh-Hans")), + ("zh-Hans", Some("zh-Hans")), + ("zh-Hans-CN", Some("zh-Hans")), + ("Chinese: Traditional", Some("zh-Hant")), + ("Traditional Chinese", Some("zh-Hant")), + ("Chinese Traditional", Some("zh-Hant")), + ("Chinese (Traditional)", Some("zh-Hant")), + ("zh_TW", Some("zh-Hant")), + ("zh-TW", Some("zh-Hant")), + ("zh_HK", Some("zh-Hant")), + ("zh-HK", Some("zh-Hant")), + ("zh-Hant", Some("zh-Hant")), + ("Chinese", Some("zh")), + ("zh", Some("zh")), + ("Japanese", Some("ja")), + ("ja", Some("ja")), + ("ja_JP", Some("ja-JP")), + ("ja-JP", Some("ja-JP")), + ("Korean", Some("ko")), + ("ko_KR", Some("ko-KR")), + ("ko-KR", Some("ko-KR")), + ("English: USA", Some("en-US")), + ("German: Swiss 2006 Reform", Some("de-CH")), + ("Norwegian: Bokmål", Some("nb")), + ("[No Language]", None), + ] { + assert_eq!(language_tag(name), tag, "{name}"); + } +} + +#[test] +fn locale_codes_map_to_their_tags() { + for (name, tag) in [ + // As InDesign-exported IDML writes them (AppliedLanguage). + ("$ID/de_DE_2006", Some("de-DE")), + ("$ID/nl_NL_2005", Some("nl-NL")), + ("$ID/English: USA", Some("en-US")), + ("$ID/English: UK", Some("en-GB")), + ("$ID/Spanish: Castilian", Some("es-ES")), + ("$ID/French", Some("fr")), + // As IDML import keeps them. + ("de_DE_2006", Some("de-DE")), + ("nl_NL_2005", Some("nl-NL")), + ("Spanish: Castilian", Some("es-ES")), + ("French", Some("fr")), + // Other locale codes. + ("de-DE", Some("de-DE")), + ("de_CH_2006", Some("de-CH")), + ("pt_BR", Some("pt-BR")), + ("en_us", Some("en-US")), + ("es_419", Some("es-419")), + ("fil_PH", Some("fil-PH")), + ("sv", Some("sv")), + ("ja", Some("ja")), + // Not codes. + ("de_DE_2006_x", None), + ("d_DE", None), + ("de_D", None), + ("de_DE_06", None), + ("1e_DE", None), + ("[No Language]", None), + ] { + assert_eq!(language_tag(name), tag, "{name}"); + } + assert_eq!(language_subtag("de-DE"), "de"); + assert_eq!(language_subtag("zh-Hant"), "zh"); + assert_eq!(language_subtag("ko"), "ko"); +} + +#[test] +fn merge_and_resolve() { + let mut a = CharAttrs { size: Some(10.0), ..Default::default() }; + a.merge(&CharAttrs { tracking: Some(20.0), size: Some(11.0), ..Default::default() }); + assert_eq!(a.size, Some(11.0)); + assert_eq!(a.count(), 2); + let mut p = CharProps::default(); + p.apply(&a); + assert_eq!(p.size, 11.0); + assert_eq!(p.tracking, 20.0); + assert_eq!(p.font_family, "Source Serif 4"); +} + +#[test] +fn tab_lists_are_checked() { + let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + assert!(TabStop::checked_list(vec![stop(f64::NAN)]).is_err()); + assert!(TabStop::checked_list(vec![stop(f64::INFINITY)]).is_err()); + assert!(TabStop::checked_list((0..=MAX_TAB_STOPS).map(|i| stop(i as f64)).collect()).is_err()); + let list = TabStop::checked_list(vec![stop(50.0), TabStop { leader: ".".into(), ..stop(10.0) }, stop(50.001)]).unwrap(); + assert_eq!(list.iter().map(|t| t.position).collect::>(), [10.0, 50.001], "sorted, one stop per position"); +} + +/// A file's tab list is repaired, never refused; a list the commands accept comes through +/// unchanged. +#[test] +fn tab_lists_from_files_are_sanitized() { + let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + let mut tabs: Vec = (0..500).rev().map(|i| stop(i as f64 * 10.0)).collect(); + tabs.push(stop(f64::NAN)); + tabs.push(stop(f64::NEG_INFINITY)); + tabs.push(TabStop { leader: "\u{7}0123456789".into(), align: TabAlign::Char, align_on: "\n,;".into(), ..stop(-3.0) }); + let list = TabStop::sanitized_list(tabs); + assert_eq!(list.len(), MAX_TAB_STOPS); + assert!(list.windows(2).all(|w| w[1].position - w[0].position >= SAME_TAB_POSITION), "sorted, one stop per position"); + let first = &list[0]; + assert_eq!((first.position, first.leader.as_str(), first.align_on.as_str()), (0.0, "01234567", ","), "the later stop at 0 wins"); + assert_eq!(list.last().map(|t| t.position), Some(990.0), "the first stops by position are kept"); + + let ok = TabStop::checked_list(vec![stop(72.0), TabStop { align: TabAlign::Char, ..stop(10.0) }]).unwrap(); + assert_eq!(TabStop::sanitized_list(ok.clone()), ok); + + // `null` is how JSON writes a non-finite position; that stop is dropped, the others kept. + let json = r#"{"tabs":[{"position":null},{"position":20000,"leader":"0123456789"},{"position":5}]}"#; + let a: ParaAttrs = serde_json::from_str(json).unwrap(); + let tabs = a.tabs.unwrap(); + assert_eq!(tabs.iter().map(|t| t.position).collect::>(), [5.0, MAX_TAB_POSITION]); + assert_eq!(tabs[1].leader, "01234567"); + let p: ParaProps = serde_json::from_str(json).unwrap(); + assert_eq!(p.tabs, tabs); + assert!(serde_json::from_str::("{}").is_err(), "a stop names its position"); +} + +#[test] +fn set_json_by_name() { + let mut a = ParaAttrs::default(); + a.set_json("spaceBefore", &serde_json::json!(6.0)).unwrap(); + a.set_json("align", &serde_json::json!("center")).unwrap(); + a.set_json("left_indent", &serde_json::json!(12)).unwrap(); + assert_eq!(a.space_before, Some(6.0)); + assert_eq!(a.align, Some(Align::Center)); + assert_eq!(a.left_indent, Some(12.0)); + assert!(a.set_json("nope", &serde_json::json!(1)).is_err()); + a.set_json("align", &serde_json::Value::Null).unwrap(); + assert_eq!(a.align, None); +} + +#[test] +fn delta_and_common() { + let a = CharProps::default(); + let mut b = a.clone(); + b.size = 18.0; + let d = a.delta(&b); + assert_eq!(d.count(), 1); + let c = CharProps::common([&a, &b]); + assert_eq!(c.size, None); + assert_eq!(c.font_family.as_deref(), Some("Source Serif 4")); +} + +#[test] +fn serde_skips_unset() { + let a = CharAttrs { size: Some(9.0), ..Default::default() }; + assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"size":9.0}"#); + let p: ParaProps = serde_json::from_str(r#"{"align":"center"}"#).unwrap(); + assert_eq!(p.align, Align::Center); + assert_eq!(p.word_space_max, 1.33); +} + +#[test] +fn same_para_style_spacing_ignore_survives_serde() { + for v in [None, Some(None), Some(Some(0.0))] { + let a = ParaAttrs { same_para_style_spacing: v, ..Default::default() }; + let back: ParaAttrs = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); + assert_eq!(back.same_para_style_spacing, v); + } +} + +#[test] +fn numbering_styles() { + assert_eq!(NumberStyle::UpperRoman.format(14), "XIV"); + assert_eq!(NumberStyle::LowerRoman.format(9), "ix"); + assert_eq!(NumberStyle::UpperLetters.format(1), "A"); + assert_eq!(NumberStyle::UpperLetters.format(27), "AA"); + assert_eq!(NumberStyle::LowerLetters.format(28), "bb"); + assert_eq!(NumberStyle::ArabicLeadingZero.format(3), "03"); + assert_eq!(NumberStyle::ArabicThreeDigits.format(7), "007"); + assert_eq!(NumberStyle::ArabicFourDigits.format(12), "0012"); + // Repeating forms stay small for huge numbers. + assert_eq!(NumberStyle::UpperRoman.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::LowerLetters.format(u32::MAX), u32::MAX.to_string()); + assert_eq!(NumberStyle::Symbols.format(u32::MAX), u32::MAX.to_string()); +} + +#[test] +fn kanji_numbers_are_written_digit_by_digit() { + for (n, s) in [ + (0, "〇"), + (1, "一"), + (5, "五"), + (10, "一〇"), + (11, "一一"), + (20, "二〇"), + (38, "三八"), + (99, "九九"), + (100, "一〇〇"), + (105, "一〇五"), + (1000, "一〇〇〇"), + (2026, "二〇二六"), + (u32::MAX, "四二九四九六七二九五"), + ] { + assert_eq!(NumberStyle::Kanji.format(n), s, "{n}"); + } +} + +#[test] +fn list_labels_expand_metacharacters() { + let p = ParaProps { number_style: NumberStyle::UpperLetters, ..Default::default() }; + assert_eq!(p.number_label(2), "B.\t"); + let p = ParaProps { number_expression: "Tabel ^#^t".into(), ..Default::default() }; + assert_eq!(p.number_label(3), "Tabel 3\t"); + assert_eq!(expand_list_text("^2.^#.^>x^^^", "4"), ".4.\u{2002}x^^"); + assert_eq!(expand_list_text("a\nb^p", "1"), "ab"); + assert_eq!(ParaProps::default().bullet_label(), "\u{2022}\t"); +} diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index d4500f7ce..c7259f317 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -125,6 +125,7 @@ impl Document { }; let mut d = Document { title: nd.title.clone(), + file_info: Default::default(), settings, spreads: vec![], parents: vec![], diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 43b65144a..1bdd088fb 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -87,7 +87,9 @@ impl Document { /// The horizontal ruler of spread `r` in pieces `(from x, to x, base)`, spread coordinates, /// covering every x: one piece, or one per page for a page origin. pub fn ruler_pieces(&self, r: SpreadRef) -> Vec<(f64, f64, Point)> { - let Some(sp) = self.spread(r) else { return Vec::new() }; + let Some(sp) = self.spread(r) else { + return Vec::new(); + }; if self.settings.ruler_origin != crate::RulerOrigin::Page || sp.pages.len() < 2 { return self.ruler_base(r, 0.0).map(|o| (f64::NEG_INFINITY, f64::INFINITY, o)).into_iter().collect(); } @@ -318,15 +320,16 @@ impl Document { let pos_to = other.frames.iter().position(|f| *f == to).unwrap_or(0); let after = other.frames.split_off(pos_to); let before = std::mem::take(&mut other.frames); - let (vertical, direction) = (other.vertical, other.direction); + let (vertical, direction, optical_margin) = (other.vertical, other.direction, other.optical_margin); let offset; { let st = self.story_mut(fs).ok_or(DocError::NoStory(fs))?; offset = if st.text.is_empty() { 0 } else { st.text.len().saturating_add(1) }; - // The story keeps its own direction, even when it was empty and takes the other's text. - let own = (st.vertical, st.direction); + // The story keeps its own direction and options, even when it was empty and takes the + // other's text. + let own = (st.vertical, st.direction, st.optical_margin); st.append_story(other); - (st.vertical, st.direction) = own; + (st.vertical, st.direction, st.optical_margin) = own; let at = st.frames.iter().position(|f| *f == from).map_or(st.frames.len(), |p| p + 1); for (k, f) in after.iter().enumerate() { st.frames.insert(at + k, *f); @@ -345,6 +348,7 @@ impl Document { ns.frames = before.clone(); ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; for f in &before { if let Some(t) = self.item_mut(*f).and_then(Item::text_frame_mut) { t.story = nid; @@ -363,7 +367,9 @@ impl Document { let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from])); let ts = self.frame_story(to)?; let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to)); - let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) }; + let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { + return self.thread(from, to); + }; self.check_thread_links(from, to, true)?; if let Some(fs) = fs { self.stories.remove(&fs); @@ -466,7 +472,9 @@ impl Document { return Err(DocError::Invalid(format!("{id} is locked or on a locked layer"))); } } - let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) }; + let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { + return Err(DocError::NoItem(to)); + }; let same_place = match (fl.spread, tl.spread) { (SpreadRef::Doc(_), SpreadRef::Doc(_)) => true, (a, b) => a == b, @@ -526,12 +534,12 @@ impl Document { /// stays with the first part and may become overset). pub fn unthread_after(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let (tail, vertical, direction) = { + let (tail, vertical, direction, optical_margin) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; let p = st.frames.iter().position(|f| *f == frame).ok_or(DocError::NoItem(frame))?; let tail = st.frames.split_off(p + 1); st.rev = st.rev.wrapping_add(1); - (tail, st.vertical, st.direction) + (tail, st.vertical, st.direction, st.optical_margin) }; if tail.is_empty() { return Ok(()); @@ -541,6 +549,7 @@ impl Document { ns.frames = tail.clone(); ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; self.stories.insert(nid, Arc::new(ns)); for f in tail { if let Some(t) = self.item_mut(f).and_then(Item::text_frame_mut) { @@ -554,20 +563,21 @@ impl Document { /// `frame` stays on the page with a new empty story. pub fn remove_from_thread(&mut self, frame: ItemId) -> Result<()> { let sid = self.item(frame).and_then(|i| i.text_frame()).map(|t| t.story).ok_or(DocError::NoItem(frame))?; - let (vertical, direction) = { + let (vertical, direction, optical_margin) = { let st = self.story_mut(sid).ok_or(DocError::NoStory(sid))?; if st.frames.len() < 2 { return Err(DocError::Invalid(format!("frame {frame} is not threaded to another frame"))); } st.frames.retain(|f| *f != frame); st.rev = st.rev.wrapping_add(1); - (st.vertical, st.direction) + (st.vertical, st.direction, st.optical_margin) }; let nid = StoryId(self.alloc()); let mut ns = Story::new(nid); ns.frames = vec![frame]; ns.vertical = vertical; ns.direction = direction; + ns.optical_margin = optical_margin; self.stories.insert(nid, Arc::new(ns)); let t = self.item_mut(frame).and_then(Item::text_frame_mut).ok_or(DocError::NoItem(frame))?; t.story = nid; @@ -638,6 +648,45 @@ impl Document { found } + /// Every item on document and parent spreads, in groups, and among the objects anchored in + /// stories, including the stories in table cells and in notes: walks that must cover every + /// placement use this, not [`Document::all_items`]. + pub fn placed_items(&self) -> Vec<&Item> { + let mut v = Vec::new(); + for sp in self.spreads.iter().chain(&self.parents) { + for it in &sp.items { + placed_item(it, 0, &mut v); + } + } + for st in self.stories.values() { + placed_story_items(st, 0, &mut v); + } + v + } + + /// Applies `f` to every placed item `hit` selects, wherever it sits: on document or parent + /// spreads, in groups, and anchored in a story (also inside table cells and notes). + /// Returns how many items `f` saw. + pub fn update_placed_items(&mut self, hit: impl Fn(&Item) -> bool, mut f: impl FnMut(&mut Item)) -> usize { + let mut n = 0; + for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { + if !sp.items.iter().any(|it| has_hit(it, &hit)) { + continue; + } + for it in Arc::make_mut(sp).items.iter_mut() { + if has_hit(it, &hit) { + update_placed_item(Arc::make_mut(it), 0, &hit, &mut f, &mut n); + } + } + } + for st in self.stories.values_mut() { + if story_has_hit(st, 0, &hit) { + update_story_items(Arc::make_mut(st), 0, &hit, &mut f, &mut n); + } + } + n + } + /// Every item id (pre-order) on document spreads. pub fn all_items(&self) -> Vec { let mut v = Vec::new(); @@ -732,6 +781,91 @@ impl Document { } } +/// How deep nested stories (a cell's text, a footnote's text, an endnote's text) are followed +/// when every placement is visited. A document nested deeper is left alone. +const MAX_PLACEMENT_DEPTH: usize = 64; + +/// The items anchored in `st`, and in the stories nested in its cells and notes, pre-order. +fn placed_story_items<'a>(st: &'a Story, depth: usize, v: &mut Vec<&'a Item>) { + if depth > MAX_PLACEMENT_DEPTH { + return; + } + for o in &st.objects { + placed_item(&o.item, 0, v); + } + for c in st.tables.values().flat_map(|t| &t.cells) { + placed_story_items(&c.text, depth + 1, v); + } + for n in st.notes.iter().chain(&st.endnotes) { + placed_story_items(&n.text, depth + 1, v); + } +} + +/// Whether `it` or anything in it is selected by `hit`. +fn has_hit(it: &Item, hit: &impl Fn(&Item) -> bool) -> bool { + fn check(it: &Item, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool { + hit(it) || (depth < MAX_PLACEMENT_DEPTH && it.children().iter().any(|child| check(child, depth + 1, hit))) + } + check(it, 0, hit) +} + +/// [`Document::update_placed_items`] for one item: only the branches holding a hit are copied. +fn update_placed_item(it: &mut Item, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) { + if hit(it) { + f(it); + *n += 1; + } + if depth >= MAX_PLACEMENT_DEPTH { + return; + } + if let Some(children) = it.children_mut() { + for c in children.iter_mut().filter(|c| has_hit(c, hit)) { + update_placed_item(Arc::make_mut(c), depth + 1, hit, f, n); + } + } +} + +/// [`Document::update_placed_items`] for the placements of one story, and of the stories nested +/// in its cells and notes. +fn update_story_items(st: &mut Story, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) { + if depth > MAX_PLACEMENT_DEPTH { + return; + } + for o in st.objects.iter_mut() { + if has_hit(&o.item, hit) { + update_placed_item(&mut Arc::make_mut(o).item, 0, hit, f, n); + } + } + for t in st.tables.values_mut().filter(|t| t.cells.iter().any(|c| story_has_hit(&c.text, depth + 1, hit))) { + for c in &mut Arc::make_mut(t).cells { + if story_has_hit(&c.text, depth + 1, hit) { + update_story_items(&mut c.text, depth + 1, hit, f, n); + } + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if story_has_hit(¬e.text, depth + 1, hit) { + update_story_items(&mut Arc::make_mut(note).text, depth + 1, hit, f, n); + } + } +} + +fn placed_item<'a>(it: &'a Item, depth: usize, v: &mut Vec<&'a Item>) { + v.push(it); + if depth < MAX_PLACEMENT_DEPTH { + for child in it.children() { + placed_item(child, depth + 1, v); + } + } +} + +fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool { + depth <= MAX_PLACEMENT_DEPTH + && (st.objects.iter().any(|o| has_hit(&o.item, hit)) + || st.tables.values().flat_map(|t| &t.cells).any(|c| story_has_hit(&c.text, depth + 1, hit)) + || st.notes.iter().chain(&st.endnotes).any(|n| story_has_hit(&n.text, depth + 1, hit))) +} + /// Does `p` (in the item's parent space) hit `it`? pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool { if let Content::Group { items } = &it.content { @@ -776,3 +910,71 @@ pub fn hit_descendant(it: &Item, p: Point, tol: f64, want: &dyn Fn(&Item) -> boo /// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type, /// either side (the type may sit on or under the path). const PATH_TYPE_REACH: f64 = 12.0; +#[cfg(test)] +mod placed_item_tests { + use super::*; + + #[test] + fn exhaustive_placements_keep_unselected_branches_shared() { + let mut d = Document::new(&Default::default()); + let layer = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); + let id = ItemId(d.alloc()); + let mut item = Item::new(id, layer, Shape::Rectangle, Default::default()); + item.content = Content::Group { items: vec![Arc::new(Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default()))] }; + let child = item.children()[0].id; + let mut table = crate::Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].text.insert_object(0, crate::AnchoredObject { item, position: Default::default() }); + d.story_mut(sid).unwrap().insert_table(0, table); + let untouched = StoryId(d.alloc()); + d.stories.insert(untouched, Arc::new(Story::new(untouched))); + let snapshot = d.clone(); + assert!(d.placed_items().iter().any(|i| i.id == child)); + assert_eq!(d.update_placed_items(|i| i.id == child, |i| i.hidden = true), 1); + assert!(d.placed_items().iter().find(|i| i.id == child).unwrap().hidden); + assert!(!snapshot.placed_items().iter().find(|i| i.id == child).unwrap().hidden); + assert!(Arc::ptr_eq(&d.stories[&untouched], &snapshot.stories[&untouched])); + assert!(Arc::ptr_eq(&d.spreads[0], &snapshot.spreads[0])); + let unchanged = d.clone(); + let mut calls = 0; + assert_eq!(d.update_placed_items(|_| false, |_| calls += 1), 0); + assert_eq!(calls, 0); + assert!(d.spreads.iter().zip(&unchanged.spreads).all(|(a, b)| Arc::ptr_eq(a, b))); + assert!(d.parents.iter().zip(&unchanged.parents).all(|(a, b)| Arc::ptr_eq(a, b))); + assert!(d.stories.iter().all(|(id, st)| Arc::ptr_eq(st, &unchanged.stories[id]))); + } + #[test] + fn exhaustive_placements_bound_nested_stories_and_groups() { + let mut d = Document::new(&Default::default()); + let layer = d.default_layer(); + let target = ItemId(d.alloc()); + let mut item = Item::new(target, layer, Shape::Rectangle, Default::default()); + for _ in 0..MAX_PLACEMENT_DEPTH + 2 { + let mut group = Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default()); + group.content = Content::Group { items: vec![Arc::new(item)] }; + item = group; + } + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + assert!(!d.placed_items().iter().any(|i| i.id == target)); + let mut calls = 0; + assert_eq!(d.update_placed_items(|i| i.id == target, |_| calls += 1), 0); + assert_eq!(calls, 0); + + let nested = ItemId(d.alloc()); + let sid = StoryId(d.alloc()); + let mut story = Story::new(sid); + story.insert_object( + 0, + crate::AnchoredObject { item: Item::new(nested, layer, Shape::Rectangle, Default::default()), position: Default::default() }, + ); + for _ in 0..MAX_PLACEMENT_DEPTH + 2 { + let mut outer = Story::new(sid); + outer.notes.push(Arc::new(crate::Footnote { id: 1, text: story })); + story = outer; + } + d.stories.insert(sid, Arc::new(story)); + assert!(!d.placed_items().iter().any(|i| i.id == nested)); + assert_eq!(d.update_placed_items(|i| i.id == nested, |_| calls += 1), 0); + assert_eq!(calls, 0); + } +} diff --git a/crates/doc/src/endnotes.rs b/crates/doc/src/endnotes.rs index da213d08c..b15bb745c 100644 --- a/crates/doc/src/endnotes.rs +++ b/crates/doc/src/endnotes.rs @@ -147,7 +147,7 @@ impl crate::Document { if st.id == sid { break; } - n += st.endnotes.len() as u32; + n = n.saturating_add(st.endnotes.len() as u32); } n } @@ -173,7 +173,7 @@ impl crate::Document { let at = out.len(); out.insert_with(at, ¬e.text.text[r].replace('\n', " "), f.clone()); } - n += 1; + n = n.saturating_add(1); } } out diff --git a/crates/doc/src/file_info.rs b/crates/doc/src/file_info.rs new file mode 100644 index 000000000..de450e687 --- /dev/null +++ b/crates/doc/src/file_info.rs @@ -0,0 +1,123 @@ +//! File › File Info: the document's title, author, description, keywords and copyright, saved +//! with the document. PDF export writes the title, author, description and keywords into the +//! file's metadata, and EPUB export writes the title and author. + +use serde::{Deserialize, Serialize}; + +use crate::Document; + +/// File › File Info. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FileInfo { + /// Document Title. Empty: exports use the document's name. + pub document_title: String, + pub author: String, + pub description: String, + /// Keywords in the order they were typed, trimmed, without empty or repeated ones. + pub keywords: Vec, + pub copyright_notice: String, + pub copyright_info_url: String, +} + +impl FileInfo { + pub fn is_empty(&self) -> bool { + *self == FileInfo::default() + } +} + +/// Keywords typed as one line: separated by semicolons or commas, trimmed, empty and repeated +/// ones dropped. +pub fn parse_keywords(text: &str) -> Vec { + clean_keywords(text.split([';', ','])) +} + +/// Keywords typed as one line, read as [`parse_keywords`] reads them, except that a keyword of +/// `kept` that holds a semicolon or comma stays one keyword wherever it still stands whole +/// between separators. +pub fn parse_keywords_keeping(text: &str, kept: &[String]) -> Vec { + let mut whole: Vec<&str> = kept.iter().map(|k| k.trim()).filter(|k| k.contains([';', ','])).collect(); + whole.sort_by_key(|k| std::cmp::Reverse(k.len())); + let separator = |c: char| c == ';' || c == ','; + let mut words = Vec::new(); + let mut rest = text; + loop { + rest = rest.trim_start_matches(|c: char| separator(c) || c.is_whitespace()); + if rest.is_empty() { + break; + } + let kept_here = whole.iter().find(|k| rest.strip_prefix(**k).is_some_and(|after| after.trim_start().chars().next().is_none_or(separator))); + let end = kept_here.map_or_else(|| rest.find(separator).unwrap_or(rest.len()), |k| k.len()); + let (word, after) = rest.split_at_checked(end).unwrap_or((rest, "")); + words.push(word); + rest = after; + } + clean_keywords(words) +} + +/// Trimmed keywords without empty or repeated ones, in their first order. +pub fn clean_keywords<'a>(words: impl IntoIterator) -> Vec { + let mut seen = std::collections::HashSet::new(); + words.into_iter().map(str::trim).filter(|w| !w.is_empty() && seen.insert(*w)).map(str::to_string).collect() +} + +impl Document { + /// The title exports write into a file's metadata: File Info's Document Title, or the + /// document's name when that is empty. + pub fn metadata_title(&self) -> &str { + let t = self.file_info.document_title.trim(); + if t.is_empty() { &self.title } else { t } + } + + /// File Info's Author, if any. + pub fn metadata_author(&self) -> Option<&str> { + let a = self.file_info.author.trim(); + (!a.is_empty()).then_some(a) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::build::NewDocument; + + #[test] + fn keywords_split_at_semicolons_and_commas() { + assert_eq!(parse_keywords(" spring; catalog, shoes ;;spring , "), ["spring", "catalog", "shoes"]); + assert!(parse_keywords(" ; , ").is_empty()); + } + + #[test] + fn keywords_with_separators_stay_whole_when_another_one_changes() { + let kept = ["Washington, DC".to_string(), "map".into(), "a; b".into()]; + assert_eq!(parse_keywords_keeping("Washington, DC; atlas", &kept), ["Washington, DC", "atlas"]); + assert_eq!(parse_keywords_keeping("atlas, a; b ;Washington, DC", &kept), ["atlas", "a; b", "Washington, DC"]); + // Edited, a keyword is read like any other text. + assert_eq!(parse_keywords_keeping("Washington, D.C.; map", &kept), ["Washington", "D.C.", "map"]); + assert_eq!(parse_keywords_keeping("Washington, DCX", &kept), ["Washington", "DCX"]); + assert_eq!(parse_keywords_keeping(" spring; catalog, shoes ;;spring , ", &[]), ["spring", "catalog", "shoes"]); + } + + #[test] + fn the_metadata_title_falls_back_to_the_document_name() { + let mut d = Document::new(&NewDocument { title: "Flyer".into(), ..Default::default() }); + assert_eq!((d.metadata_title(), d.metadata_author()), ("Flyer", None)); + d.file_info.document_title = " Spring Catalog ".into(); + d.file_info.author = " Ana ".into(); + assert_eq!((d.metadata_title(), d.metadata_author()), ("Spring Catalog", Some("Ana"))); + d.file_info.document_title = " ".into(); + assert_eq!(d.metadata_title(), "Flyer"); + } + + #[test] + fn a_document_without_file_info_saves_as_before() { + let mut d = Document::new(&NewDocument::default()); + let v = serde_json::to_value(&d).unwrap(); + assert!(v.get("fileInfo").is_none()); + d.file_info.keywords = vec!["a".into()]; + d.file_info.copyright_notice = "© 2026 Ana".into(); + let json = serde_json::to_string(&d).unwrap(); + let back: Document = serde_json::from_str(&json).unwrap(); + assert_eq!(back.file_info, d.file_info); + } +} diff --git a/crates/doc/src/item.rs b/crates/doc/src/item.rs index 6d1308240..4c930c878 100644 --- a/crates/doc/src/item.rs +++ b/crates/doc/src/item.rs @@ -9,1345 +9,38 @@ use serde::{Deserialize, Serialize}; use crate::ids::{AssetId, ItemId, LayerId, StoryId}; -/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Fill { - pub swatch: String, - pub tint: f32, - /// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds). - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_angle: Option, - /// Gradient Swatch tool vector in the item's own space: start and end of a linear gradient, - /// centre and radius point of a radial one (`None` = fit to bounds at `gradient_angle`). - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_vector: Option<[f64; 4]>, - pub overprint: bool, -} +mod content; +mod effects; +mod graphic; +mod paint; +mod text_frame; -impl Default for Fill { - fn default() -> Self { - Fill::none() - } -} - -impl Fill { - /// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and - /// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's - /// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box); - /// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's - /// length, measured with the box made square, sets its size. - pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) { - let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 }; - match self.gradient_vector { - Some([x0, y0, x1, y1]) => { - let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); - (designcraft_geom::Point::new(x0, y0), r, r * k) - } - // A box with no width or height (a straight line's) has no ellipse: a circle across - // its length. - None if b.width() <= 1e-9 || b.height() <= 1e-9 => { - let r = b.width().max(b.height()).max(1e-3) / 2.0; - (b.center(), r, r) - } - None => (b.center(), b.width() / 2.0, b.height() / 2.0), - } - } - - /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the - /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its - /// centre. A box with no extent along that angle (a straight line, across it) takes its longer - /// side. - pub fn linear_axis(&self, b: Rect) -> (Point, Point) { - if let Some([x0, y0, x1, y1]) = self.gradient_vector { - return (Point::new(x0, y0), Point::new(x1, y1)); - } - let a = self.gradient_angle.unwrap_or(0.0).to_radians(); - let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; - let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; - let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; - (b.center() - d, b.center() + d) - } - - pub fn none() -> Self { - Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } - } - pub fn swatch(name: &str) -> Self { - Fill { swatch: name.into(), ..Fill::none() } - } - pub fn is_none(&self) -> bool { - self.swatch == designcraft_color::swatch::NONE - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StrokeAlign { - #[default] - Center, - Inside, - Outside, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Cap { - #[default] - Butt, - Round, - Projecting, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Join { - #[default] - Miter, - Round, - Bevel, -} - -/// The shortest non-zero dash or gap a command accepts, in points. -pub const MIN_DASH: f64 = 0.1; -/// The longest dash or gap a command accepts, in points. -pub const MAX_DASH: f64 = 10_000.0; -/// The most lengths a dash pattern holds. -pub const MAX_DASH_ENTRIES: usize = 64; -/// The most pattern repeats a renderer expands along one path; finer patterns draw solid. -pub const MAX_DASH_PERIODS: f64 = 20_000.0; - -/// An upper bound of the length of `bp`: the length of its control polygon. -pub fn path_length_bound(bp: &designcraft_geom::BezPath) -> f64 { - use designcraft_geom::PathSeg; - bp.segments() - .map(|s| match s { - PathSeg::Line(l) => l.p0.distance(l.p1), - PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2), - PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3), - }) - .sum() -} - -/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum StrokeType { - #[default] - Solid, - Dashed { - pattern: Vec, - }, - Dotted, - ThickThin, - ThinThick, - ThinThin, - ThickThick, - ThinThickThin, - ThickThinThick, - Wavy, - Hashed, - /// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge. - Stripes { - bands: Vec<(f64, f64)>, - }, - /// A named stroke style of the document (Stroke Styles), by name. - Style { - name: String, - }, -} - -impl StrokeType { - pub fn label(&self) -> &'static str { - match self { - StrokeType::Solid => "Solid", - StrokeType::Dashed { .. } => "Dashed", - StrokeType::Dotted => "Dotted", - StrokeType::ThickThin => "Thick - Thin", - StrokeType::ThinThick => "Thin - Thick", - StrokeType::ThinThin => "Thin - Thin", - StrokeType::ThickThick => "Thick - Thick", - StrokeType::ThinThickThin => "Thin - Thick - Thin", - StrokeType::ThickThinThick => "Thick - Thin - Thick", - StrokeType::Wavy => "Wavy", - StrokeType::Hashed => "Straight Hash", - StrokeType::Stripes { .. } => "Stripes", - StrokeType::Style { .. } => "Custom", - } - } - - /// Whether `pattern` is a dash pattern commands accept: 1 to [`MAX_DASH_ENTRIES`] lengths, - /// each 0 or from [`MIN_DASH`] to [`MAX_DASH`] points, not all 0. - pub fn valid_dash_pattern(pattern: &[f64]) -> bool { - !pattern.is_empty() - && pattern.len() <= MAX_DASH_ENTRIES - && pattern.iter().all(|x| *x == 0.0 || (MIN_DASH..=MAX_DASH).contains(x)) - && pattern.iter().any(|x| *x > 0.0) - } - - /// Whether a renderer may expand `pattern` along a path at most `length` points long: at - /// most [`MAX_DASH_ENTRIES`] finite, non-negative lengths that repeat at most - /// [`MAX_DASH_PERIODS`] times. Otherwise the stroke draws solid, so a degenerate pattern - /// from a file can't stall rendering. - pub fn expandable_dashes(pattern: &[f64], length: f64) -> bool { - let period: f64 = pattern.iter().sum(); - pattern.len() <= MAX_DASH_ENTRIES - && pattern.iter().all(|x| x.is_finite() && *x >= 0.0) - && period > 0.0 - && length.is_finite() - && length / period <= MAX_DASH_PERIODS - } - - /// The bands of a striped type (start, width as fractions of the weight). - pub fn bands(&self) -> Option> { - Some(match self { - StrokeType::ThickThin => vec![(0.0, 0.5), (0.75, 0.25)], - StrokeType::ThinThick => vec![(0.0, 0.25), (0.5, 0.5)], - StrokeType::ThinThin => vec![(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0 / 3.0)], - StrokeType::ThickThick => vec![(0.0, 0.4), (0.6, 0.4)], - StrokeType::ThinThickThin => vec![(0.0, 0.2), (0.35, 0.3), (0.8, 0.2)], - StrokeType::ThickThinThick => vec![(0.0, 0.35), (0.45, 0.1), (0.65, 0.35)], - StrokeType::Stripes { bands } => bands.clone(), - _ => return None, - }) - } - - /// The filled area for types drawn as fills (stripes, wavy, hash); `None` for solid, dashed - /// and dotted strokes (and unresolved named styles). - pub fn outline(&self, bp: &designcraft_geom::BezPath, weight: f64, tol: f64) -> Option { - use designcraft_geom::stroke_style as ss; - match self { - StrokeType::Wavy => Some(ss::wavy(bp, weight, tol)), - StrokeType::Hashed => Some(ss::hashed(bp, weight, tol)), - t => t.bands().map(|b| ss::stripes(bp, weight, &b, tol)), - } - } -} - -/// A named custom stroke style (Stroke Styles dialog): dashes, dots or stripes. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct StrokeStyleDef { - pub name: String, - pub kind: StrokeType, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Arrowhead { - #[default] - None, - SimpleWide, - Simple, - Triangle, - TriangleWide, - Barbed, - Curved, - Circle, - CircleSolid, - Square, - SquareSolid, - Bar, -} - -impl Arrowhead { - pub const ALL: [Arrowhead; 12] = [ - Arrowhead::None, - Arrowhead::Simple, - Arrowhead::SimpleWide, - Arrowhead::Triangle, - Arrowhead::TriangleWide, - Arrowhead::Barbed, - Arrowhead::Curved, - Arrowhead::Circle, - Arrowhead::CircleSolid, - Arrowhead::Square, - Arrowhead::SquareSolid, - Arrowhead::Bar, - ]; - - pub fn label(self) -> &'static str { - match self { - Arrowhead::None => "None", - Arrowhead::Simple => "Simple", - Arrowhead::SimpleWide => "Simple - Wide", - Arrowhead::Triangle => "Triangle", - Arrowhead::TriangleWide => "Triangle - Wide", - Arrowhead::Barbed => "Barbed", - Arrowhead::Curved => "Curved", - Arrowhead::Circle => "Circle", - Arrowhead::CircleSolid => "Circle - Solid", - Arrowhead::Square => "Square", - Arrowhead::SquareSolid => "Square - Solid", - Arrowhead::Bar => "Bar", - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Stroke { - pub swatch: String, - pub tint: f32, - pub weight: f64, - #[serde(rename = "type")] - pub kind: StrokeType, - pub align: StrokeAlign, - pub cap: Cap, - pub join: Join, - pub miter_limit: f64, - pub start: Arrowhead, - pub end: Arrowhead, - /// Gap colour for dashed/striped strokes. - pub gap_swatch: String, - pub gap_tint: f32, - pub overprint: bool, - /// Overprint Gap (Attributes panel). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub gap_overprint: bool, - /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_angle: Option, - /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. - #[serde(skip_serializing_if = "Option::is_none")] - pub gradient_vector: Option<[f64; 4]>, -} - -impl Default for Stroke { - /// InDesign's default for drawn shapes: 1 pt black. - fn default() -> Self { - Stroke { - swatch: designcraft_color::swatch::BLACK.into(), - tint: 1.0, - weight: 1.0, - kind: StrokeType::Solid, - align: StrokeAlign::Center, - cap: Cap::Butt, - join: Join::Miter, - miter_limit: 4.0, - start: Arrowhead::None, - end: Arrowhead::None, - gap_swatch: designcraft_color::swatch::NONE.into(), - gap_tint: 1.0, - overprint: false, - gap_overprint: false, - gradient_angle: None, - gradient_vector: None, - } - } -} - -impl Stroke { - /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: - /// a gradient swatch on a stroke runs across the object's box as on its fill. - pub fn as_fill(&self) -> Fill { - Fill { - swatch: self.swatch.clone(), - tint: self.tint, - gradient_angle: self.gradient_angle, - gradient_vector: self.gradient_vector, - overprint: self.overprint, - } - } - - /// How far the painted stroke can reach beyond the path (arrowheads reach further). - pub fn extent(&self) -> f64 { - if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } - } - - pub fn none() -> Self { - Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() } - } - pub fn is_none(&self) -> bool { - self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0 - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum VerticalJustification { - #[default] - Top, - Center, - Bottom, - Justify, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FirstBaseline { - #[default] - Ascent, - CapHeight, - Leading, - XHeight, - Fixed, - /// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo - /// ascender when the typo ascender and descender span one em; else half an em above half the - /// cap height. - EmboxHeight, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum AutoSize { - #[default] - Off, - HeightOnly, - WidthOnly, - HeightAndWidth, - HeightAndWidthProportionally, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ColumnsKind { - #[default] - FixedNumber, - FixedWidth, - FlexibleWidth, -} - -/// Text Frame Options. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TextFrameOptions { - pub columns: u32, - pub columns_kind: ColumnsKind, - pub gutter: f64, - /// Column width for fixed/flexible width columns. - pub column_width: f64, - pub balance_columns: bool, - /// Inset spacing: top, left, bottom, right. - pub inset: [f64; 4], - pub vertical_justification: VerticalJustification, - pub vj_paragraph_spacing_limit: f64, - pub first_baseline: FirstBaseline, - pub first_baseline_min: f64, - pub ignore_wrap: bool, - pub auto_size: AutoSize, - /// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing. - pub auto_size_ref: u8, - /// Auto-size never makes the frame shorter than `auto_size_min_height` while this is on. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub auto_size_use_min_height: bool, - #[serde(skip_serializing_if = "is_default")] - pub auto_size_min_height: f64, - /// Auto-size never makes the frame narrower than `auto_size_min_width` while this is on. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub auto_size_use_min_width: bool, - #[serde(skip_serializing_if = "is_default")] - pub auto_size_min_width: f64, - /// Width-growing auto-size sets each paragraph on one line (the frame widens to the longest). - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub auto_size_no_line_breaks: bool, - pub column_rule: bool, - pub column_rule_weight: f64, - pub column_rule_color: String, - /// 0..1. - pub column_rule_tint: f32, - /// Horizontal offset of each rule from the middle of its gutter. - pub column_rule_offset: f64, - /// The rule starts this far below the column top and ends this far above its bottom. - pub column_rule_top_inset: f64, - pub column_rule_bottom_inset: f64, - /// Use a custom baseline grid for this frame. - #[serde(skip_serializing_if = "Option::is_none")] - pub baseline_grid: Option, - /// Type on a Path: the text runs along the item's path instead of filling it. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -/// A text frame's custom baseline grid. Saved as `[start, increment]`. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(from = "(f64, f64)", into = "(f64, f64)")] -pub struct FrameBaselineGrid { - /// The first grid line, down from the top of the page. - pub start: f64, - /// The distance between grid lines; 0 or less turns the grid off. - pub increment: f64, -} - -impl From<(f64, f64)> for FrameBaselineGrid { - fn from((start, increment): (f64, f64)) -> Self { - FrameBaselineGrid { start, increment } - } -} - -impl From for (f64, f64) { - fn from(g: FrameBaselineGrid) -> Self { - (g.start, g.increment) - } -} - -/// Type on a Path Options. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct PathType { - /// Where the text starts, as a distance along the path (the start bracket). - pub start: f64, - /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's - /// end. Text past it is overset. - #[serde(skip_serializing_if = "Option::is_none")] - pub end: Option, - /// Run the other way (the text sits on the other side). - pub flip: bool, - pub align: PathAlign, -} - -/// Which part of the type sits on the path. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum PathAlign { - #[default] - Baseline, - Ascender, - Descender, - Center, -} - -impl Default for TextFrameOptions { - fn default() -> Self { - TextFrameOptions { - columns: 1, - columns_kind: ColumnsKind::FixedNumber, - gutter: 12.0, - column_width: 0.0, - balance_columns: false, - inset: [0.0; 4], - vertical_justification: VerticalJustification::Top, - vj_paragraph_spacing_limit: 0.0, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - ignore_wrap: false, - auto_size: AutoSize::Off, - auto_size_ref: 4, - auto_size_use_min_height: false, - auto_size_min_height: 0.0, - auto_size_use_min_width: false, - auto_size_min_width: 0.0, - auto_size_no_line_breaks: false, - column_rule: false, - column_rule_weight: 1.0, - column_rule_color: designcraft_color::swatch::BLACK.into(), - column_rule_tint: 1.0, - column_rule_offset: 0.0, - column_rule_top_inset: 0.0, - column_rule_bottom_inset: 0.0, - baseline_grid: None, - path: None, - } - } -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WrapMode { - #[default] - None, - BoundingBox, - Contour, - JumpObject, - JumpToNextColumn, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WrapSide { - #[default] - BothSides, - RightSide, - LeftSide, - TowardsSpine, - AwayFromSpine, - LargestArea, -} - -/// Text Wrap panel › Contour Options › Type: the outline a contour wrap follows on a graphic frame. -/// Other frames always wrap around their own path. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ContourType { - /// The placed graphic's rectangle (within the frame). - BoundingBox, - /// Edges detected in the image. Composition follows the frame path. - DetectEdges, - /// The image's alpha channel. Composition follows the frame path. - AlphaChannel, - /// A path saved in the image. Composition follows the frame path. - PhotoshopPath, - /// The frame's path. - GraphicFrame, - /// The graphic's clipping path, or the frame's path when it has none. - #[default] - SameAsClipping, -} - -/// Text Wrap panel settings. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TextWrap { - pub mode: WrapMode, - /// Top, left, bottom, right offsets. - pub offsets: [f64; 4], - pub invert: bool, - pub side: WrapSide, - pub contour: ContourType, - /// A custom wrap boundary in the item's inner space, offsets already included. Bounding-box - /// and contour wraps follow it instead of the frame; other modes use its bounds. Resizing the - /// item transforms it with the path; other path edits keep it as it is. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, -} - -impl TextWrap { - pub fn is_default(&self) -> bool { - *self == TextWrap::default() - } -} - -/// Frame fitting options (Object → Fitting → Frame Fitting Options). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Fitting { - #[default] - None, - FillProportionally, - FitProportionally, - FitContentToFrame, - CenterContent, -} - -/// A placed graphic inside a frame. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Graphic { - pub asset: AssetId, - /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). - pub size: (f64, f64), - /// Graphic space (0,0)-(w,h) → frame inner space. - pub xf: Affine, - /// Clipping path: only the part of the graphic inside it shows. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clip: Option>, - #[serde(default)] - pub auto_fit: Fitting, - /// Frame Fitting Options › Align From: the reference point (0–8, row-major; 4 = centre). - #[serde(default = "center_ref")] - pub fit_align: u8, - /// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space). - #[serde(default)] - pub crop: [f64; 4], - /// Text wrap set on the graphic itself (Direct Selection), around the part its frame shows. - #[serde(default, skip_serializing_if = "TextWrap::is_default")] - pub wrap: TextWrap, - /// The swatch applied to the image itself: InDesign colours a greyscale or bitmap image with - /// it, colour images ignore it. `None` (the default, like InDesign's Black) leaves it as is. - #[serde(default, skip_serializing_if = "Fill::is_none")] - pub fill: Fill, -} - -fn center_ref() -> u8 { - 4 -} - -/// Most anchors a clipping path read from a file keeps (detected edges can be dense). -pub const MAX_CLIP_ANCHORS: usize = 100_000; - -/// Clipping Path › Type: where the clipping path came from. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ClippingKind { - /// Traced from the image's edges against a light background. - DetectEdges, - /// Traced from one of the image's alpha channels. - AlphaChannel, - /// A path saved in the image file. - PhotoshopPath, - /// One of the above, edited by hand. - UserModifiedPath, -} - -/// A placed graphic's clipping path. The tracing options are kept as the file had them; the -/// outline itself is `path`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClippingPath { - pub kind: ClippingKind, - /// The outline in graphic space ((0,0)-(w,h) is the graphic's natural rectangle). - pub path: PathData, - /// Show the graphic outside the path instead of inside it. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub invert: bool, - /// Holes inside the traced shape are part of the path. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub include_inside_edges: bool, - /// Detect Edges: trace only the part of the image the frame shows. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub restrict_to_frame: bool, - /// Trace the full-resolution image rather than the screen proxy. - #[serde(default = "yes")] - pub use_high_resolution: bool, - /// Detect Edges / Alpha Channel: the darkness or opacity level that counts as inside (0–255). - #[serde(default = "clip_threshold")] - pub threshold: f64, - /// How closely the traced path follows the pixels, in pixels. - #[serde(default = "clip_tolerance")] - pub tolerance: f64, - /// Inset Frame: how far the path is pulled in (points; negative pushes it out). - #[serde(default)] - pub inset: f64, - /// The alpha channel or image path the clip came from. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub source_name: String, - /// Index of the alpha channel (-1 = none). - #[serde(default = "no_index")] - pub source_index: i32, -} - -fn yes() -> bool { - true -} -fn clip_threshold() -> f64 { - 25.0 -} -fn clip_tolerance() -> f64 { - 2.0 -} -fn no_index() -> i32 { - -1 -} - -impl ClippingPath { - /// A clipping path of `kind` with the default options. - pub fn new(kind: ClippingKind, path: PathData) -> Self { - Self { - kind, - path, - invert: false, - include_inside_edges: false, - restrict_to_frame: false, - use_high_resolution: true, - threshold: clip_threshold(), - tolerance: clip_tolerance(), - inset: 0.0, - source_name: String::new(), - source_index: no_index(), - } - } -} - -/// Liquid Layout object rules: which edges stay at their distance from the page edge, and -/// whether the object may stretch. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct ObjectLiquid { - pub resize_width: bool, - pub resize_height: bool, - pub pin_top: bool, - pub pin_bottom: bool, - pub pin_left: bool, - pub pin_right: bool, -} - -/// Object Export Options: tagged PDF and EPUB/HTML handling of one object. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct ExportOptions { - /// Tagged PDF: the object is decoration (an artifact), not content. - pub artifact: bool, - /// EPUB/HTML: export the object as an image (whatever it is). - pub rasterize: bool, - /// EPUB/HTML: "left", "center" or "right" ("" = as the flow puts it). - pub align: String, - /// EPUB/HTML: start a new page (screen) before the object. - pub page_break_before: bool, -} - -impl ExportOptions { - pub fn is_default(&self) -> bool { - *self == ExportOptions::default() - } -} - -/// A PDF form field. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FormField { - pub kind: FieldKind, - /// Field name (unique in the exported PDF). - pub name: String, - /// Default value: the text, the chosen option, or "On"/"" for a check box. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub value: String, - /// Choices of a combo box or list box. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub multiline: bool, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub required: bool, - /// Font size of the field's text (0 = auto). - #[serde(default)] - pub font_size: f64, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FieldKind { - TextField, - CheckBox, - ComboBox, - ListBox, - Signature, -} - -/// The source and template of a live caption. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LiveCaption { - pub source: ItemId, - pub template: String, -} - -/// Media panel options for a placed video or sound. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct MediaOptions { - pub play_on_page_load: bool, - #[serde(rename = "loop")] - pub looping: bool, - /// Show the player controls. - pub controls: bool, - /// Poster image (shown on the page and in print); `None` = a placeholder frame. - #[serde(skip_serializing_if = "Option::is_none")] - pub poster: Option, -} - -impl Item { - /// The graphic to draw for this frame: a media frame with a poster draws the poster - /// stretched over the media rectangle. - pub fn drawn_graphic(&self, assets: &std::collections::BTreeMap>) -> Option { - let Content::Graphic(g) = &self.content else { return None }; - if let Some(poster) = self.media.as_ref().and_then(|m| m.poster) - && let Some((pw, ph)) = assets.get(&poster).and_then(|a| a.pixels) - { - let (pw, ph) = (pw.max(1) as f64, ph.max(1) as f64); - return Some(Graphic { asset: poster, size: (pw, ph), xf: g.xf * Affine::scale_non_uniform(g.size.0 / pw, g.size.1 / ph), ..g.clone() }); - } - Some(g.clone()) - } -} - -/// Vertical text: composed in a box `area.height()` wide and `area.width()` tall at the origin, -/// placed so lines go down and follow each other leftwards (a clockwise quarter turn). -pub fn vertical_text_xf(area: Rect) -> Affine { - Affine::new([0.0, 1.0, -1.0, 0.0, area.x1, area.y0]) -} - -/// "video" or "audio" for a media MIME type. -pub fn media_kind(mime: &str) -> Option<&'static str> { - if mime.starts_with("video/") { - Some("video") - } else if mime.starts_with("audio/") { - Some("audio") - } else { - None - } -} - -/// MIME type of a media file by its extension. -pub fn media_mime(name: &str) -> Option<&'static str> { - let ext = name.rsplit('.').next()?.to_ascii_lowercase(); - Some(match ext.as_str() { - "mp4" | "m4v" => "video/mp4", - "mov" => "video/quicktime", - "webm" => "video/webm", - "ogv" => "video/ogg", - "mp3" => "audio/mpeg", - "m4a" => "audio/mp4", - "wav" => "audio/wav", - "ogg" | "oga" => "audio/ogg", - "aac" => "audio/aac", - _ => return None, - }) -} - -/// A button's On Release action. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "kind")] -pub enum ButtonAction { - GoToPage { page: usize }, - GoToFirstPage, - GoToLastPage, - GoToNextPage, - GoToPreviousPage, - GoToUrl { url: String }, -} +pub use content::{Content, Shape}; +pub use effects::{ + Bevel, DirectionalFeather, DropShadow, Effects, FeatherCorners, FeatherOptions, GlowTechnique, GradientFeather, InnerGlow, InnerShadow, + OuterGlow, Satin, +}; +pub use graphic::{ + ButtonAction, ClippingKind, ClippingPath, ExportOptions, FieldKind, Fitting, FormField, GRAPHIC_LAYER_RENDERING_WARNING, Graphic, + GraphicLayerOptions, LiveCaption, MAX_CLIP_ANCHORS, MediaOptions, ObjectLiquid, media_kind, media_mime, +}; +pub use paint::{ + Arrowhead, Cap, Fill, Join, MAX_DASH, MAX_DASH_ENTRIES, MAX_DASH_PERIODS, MIN_DASH, Stroke, StrokeAlign, StrokeStyleDef, StrokeType, + path_length_bound, +}; +pub use text_frame::{ + AutoSize, ColumnsKind, ContourType, FirstBaseline, FrameBaselineGrid, FrameGridRelative, PathAlign, PathType, TextFrame, TextFrameOptions, + TextWrap, VerticalJustification, WrapMode, WrapSide, vertical_text_xf, +}; fn is_zero_usize(v: &usize) -> bool { *v == 0 } -impl Graphic { - /// The part of the graphic its frame shows, in the frame's inner space (bounding boxes); - /// None when nothing shows. - pub fn shown_bounds(&self, frame: Rect) -> Option { - let r = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, self.size.0, self.size.1)).intersect(frame); - (r.width() > 0.0 && r.height() > 0.0).then_some(r) - } - - /// The region the clipping path lets through, in frame inner space, and whether it is filled - /// even-odd (an inverted clip: the graphic's rectangle minus the path). `None` when the graphic - /// has no clipping path or the path is empty. - pub fn clip_region(&self) -> Option<(designcraft_geom::BezPath, bool)> { - use designcraft_geom::Shape; - let c = self.clip.as_ref().filter(|c| !c.path.is_empty())?; - let mut path = c.path.to_bezpath(); - if !c.invert { - path.apply_affine(self.xf); - return Some((path, false)); - } - let outer = Rect::new(0.0, 0.0, self.size.0, self.size.1).union(path.bounding_box()).inflate(1.0, 1.0); - let mut region = outer.to_path(0.1); - region.extend(path.iter()); - region.apply_affine(self.xf); - Some((region, true)) - } - - /// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`, - /// honouring the crop amounts and the reference point. - pub fn fitted(&self, frame: Rect, mode: Fitting) -> Option { - let (nw, nh) = self.size; - if nw <= 0.0 || nh <= 0.0 { - return None; - } - let [t, l, b, r] = self.crop; - let area = Rect::new(frame.x0 - l, frame.y0 - t, frame.x1 + r, frame.y1 + b); - if area.width() <= 0.0 || area.height() <= 0.0 { - return None; - } - let (fx, fy) = ([0.0, 0.5, 1.0][(self.fit_align % 3) as usize], [0.0, 0.5, 1.0][(self.fit_align / 3).min(2) as usize]); - let place = |w: f64, h: f64| (area.x0 + (area.width() - w) * fx, area.y0 + (area.height() - h) * fy); - Some(match mode { - Fitting::None => return None, - Fitting::FillProportionally | Fitting::FitProportionally => { - let k = if mode == Fitting::FillProportionally { - (area.width() / nw).max(area.height() / nh) - } else { - (area.width() / nw).min(area.height() / nh) - }; - Affine::translate(place(nw * k, nh * k)) * Affine::scale(k) - } - Fitting::FitContentToFrame => Affine::translate((area.x0, area.y0)) * Affine::scale_non_uniform(area.width() / nw, area.height() / nh), - Fitting::CenterContent => { - let cur = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); - let (x, y) = place(cur.width(), cur.height()); - Affine::translate((x - cur.x0, y - cur.y0)) * self.xf - } - }) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TextFrame { - pub story: StoryId, - #[serde(default)] - pub options: TextFrameOptions, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum Content { - #[default] - Unassigned, - Text(TextFrame), - Graphic(Graphic), - Group { - items: Vec>, - }, -} - -/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Shape { - #[default] - Rectangle, - Oval, - Polygon, - GraphicLine, - Path, - Group, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct DropShadow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub angle: f64, - /// Use Global Light (the document angle instead of `angle`). - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub global_light: bool, - pub distance: f64, - pub size: f64, - pub spread: f64, - /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before - /// effects had modes reads Normal, as they were drawn then. - #[serde(default = "normal_blend")] - pub mode: BlendMode, -} - -/// The blend of an effect saved before effects had one. -fn normal_blend() -> BlendMode { - BlendMode::Normal -} - -impl Default for DropShadow { - fn default() -> Self { - DropShadow { - on: false, - mode: BlendMode::Multiply, - color: designcraft_color::swatch::BLACK.into(), - opacity: 0.75, - angle: 120.0, - distance: 7.0, - size: 5.0, - spread: 0.0, - global_light: false, - } - } -} - -/// Inner shadow: a blurred, offset shadow inside the object's edge. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct InnerShadow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub angle: f64, - /// Use Global Light (the document angle instead of `angle`). - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub global_light: bool, - pub distance: f64, - pub size: f64, - /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. - pub choke: f64, - /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). - #[serde(default = "normal_blend")] - pub mode: BlendMode, -} - -impl Default for InnerShadow { - fn default() -> Self { - InnerShadow { - on: false, - mode: BlendMode::Multiply, - color: designcraft_color::swatch::BLACK.into(), - opacity: 0.75, - angle: 120.0, - distance: 7.0, - size: 7.0, - choke: 0.0, - global_light: false, - } - } -} - -/// Outer glow: a blurred silhouette around the object, without offset. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct OuterGlow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub size: f64, - /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. - pub spread: f64, - /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). - #[serde(default = "normal_blend")] - pub mode: BlendMode, -} - -impl Default for OuterGlow { - fn default() -> Self { - // InDesign starts a glow screened in white. - OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } - } -} - fn is_default(v: &T) -> bool { *v == T::default() } -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Effects { - /// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows - /// the silhouette instead of blurring it. - pub drop_shadow: DropShadow, - /// Basic feather width (0 = off). - pub feather: f64, - /// Basic feather's choke and corners. - #[serde(skip_serializing_if = "is_default")] - pub feather_options: FeatherOptions, - #[serde(skip_serializing_if = "is_default")] - pub inner_shadow: InnerShadow, - #[serde(skip_serializing_if = "is_default")] - pub outer_glow: OuterGlow, - #[serde(skip_serializing_if = "is_default")] - pub gradient_feather: GradientFeather, - #[serde(skip_serializing_if = "is_default")] - pub inner_glow: InnerGlow, - #[serde(skip_serializing_if = "is_default")] - pub bevel: Bevel, - #[serde(skip_serializing_if = "is_default")] - pub satin: Satin, - #[serde(skip_serializing_if = "is_default")] - pub directional_feather: DirectionalFeather, -} - -/// Basic Feather › Corners: how the fade follows the shape's corners. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FeatherCorners { - /// Mitred: the fade keeps the corners sharp. - Sharp, - /// The fade rounds the corners by its width. - Rounded, - /// From opaque to transparent, softly (InDesign's default). - #[default] - Diffused, -} - -/// Basic Feather options besides its width. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct FeatherOptions { - /// Percentage (0–100) of the width that stays opaque: the fade gets shorter. - pub choke: f64, - pub corners: FeatherCorners, -} - -/// Inner/Outer Glow › Technique. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum GlowTechnique { - /// A soft, blurred glow. - #[default] - Softer, - /// A glow that keeps closer to the shape's edges. - Precise, -} - -/// Directional Feather: each side fades over its own width (item space, before rotation). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct DirectionalFeather { - pub on: bool, - /// Top, left, bottom, right widths in points. - pub widths: [f64; 4], -} - -/// Inner Glow: a glow inside the edge (or from the centre). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct InnerGlow { - pub on: bool, - pub color: String, - pub opacity: f32, - pub size: f64, - /// Percentage (0–100) of `size` that hardens the glow. - pub choke: f64, - /// Glow from the centre instead of the edges. - pub center: bool, - /// How the glow blends with the object (InDesign: Screen). - pub mode: BlendMode, - pub technique: GlowTechnique, -} - -impl Default for InnerGlow { - fn default() -> Self { - InnerGlow { - on: false, - color: designcraft_color::swatch::PAPER.into(), - opacity: 0.75, - size: 7.0, - choke: 0.0, - center: false, - mode: BlendMode::Screen, - technique: GlowTechnique::Softer, - } - } -} - -/// Bevel and Emboss (inner bevel): a highlight on the lit edges, a shadow on the others. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Bevel { - pub on: bool, - pub size: f64, - /// Depth in percent (scales the highlight and shadow offset). - pub depth: f64, - pub angle: f64, - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub global_light: bool, - pub highlight: String, - pub highlight_opacity: f32, - pub shadow: String, - pub shadow_opacity: f32, -} - -impl Default for Bevel { - fn default() -> Self { - Bevel { - on: false, - size: 7.0, - depth: 100.0, - angle: 120.0, - global_light: true, - highlight: designcraft_color::swatch::PAPER.into(), - highlight_opacity: 0.75, - shadow: designcraft_color::swatch::BLACK.into(), - shadow_opacity: 0.75, - } - } -} - -/// Satin: soft interior shading from two offset copies of the shape. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Satin { - pub on: bool, - pub color: String, - pub opacity: f32, - pub angle: f64, - pub distance: f64, - pub size: f64, - pub invert: bool, -} - -impl Default for Satin { - fn default() -> Self { - Satin { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.5, angle: 120.0, distance: 7.0, size: 7.0, invert: false } - } -} - -/// Gradient Feather: the object fades from `start` to `end` opacity along a gradient. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct GradientFeather { - pub on: bool, - pub radial: bool, - /// Degrees (linear), when no vector was dragged. - pub angle: f64, - pub start: f32, - pub end: f32, - /// Gradient Feather tool vector in the item's space: start and end (linear), centre and - /// radius point (radial). - #[serde(skip_serializing_if = "Option::is_none")] - pub vector: Option<[f64; 4]>, -} - -impl Default for GradientFeather { - fn default() -> Self { - GradientFeather { on: false, radial: false, angle: 0.0, start: 1.0, end: 0.0, vector: None } - } -} - -impl GradientFeather { - /// The gradient's two points in item space for an object with inner bounds `b`. - pub fn points(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, designcraft_geom::Point) { - use designcraft_geom::{Point, Vec2}; - if let Some([x0, y0, x1, y1]) = self.vector { - return (Point::new(x0, y0), Point::new(x1, y1)); - } - let c = b.center(); - if self.radial { - return (c, c + Vec2::new(b.width().max(b.height()) / 2.0, 0.0)); - } - let a = self.angle.to_radians(); - let d = Vec2::new(a.cos(), -a.sin()); - let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; - (c - d * half, c + d * half) - } -} - -impl Effects { - /// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances - /// not below 0, or an error naming the first value that isn't a finite number. - pub fn sanitized(mut self) -> Result { - fn finite(name: &str, v: f64) -> Result { - if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) } - } - let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0)); - let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0)); - let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32); - let ds = &mut self.drop_shadow; - ds.opacity = opacity("dropShadow.opacity", ds.opacity)?; - ds.angle = finite("dropShadow.angle", ds.angle)?; - ds.distance = len("dropShadow.distance", ds.distance)?; - ds.size = len("dropShadow.size", ds.size)?; - ds.spread = pct("dropShadow.spread", ds.spread)?; - self.feather = len("feather", self.feather)?; - let e = &mut self.inner_shadow; - e.opacity = opacity("innerShadow.opacity", e.opacity)?; - e.angle = finite("innerShadow.angle", e.angle)?; - e.distance = len("innerShadow.distance", e.distance)?; - e.size = len("innerShadow.size", e.size)?; - e.choke = pct("innerShadow.choke", e.choke)?; - let e = &mut self.outer_glow; - e.opacity = opacity("outerGlow.opacity", e.opacity)?; - e.size = len("outerGlow.size", e.size)?; - e.spread = pct("outerGlow.spread", e.spread)?; - let e = &mut self.gradient_feather; - e.angle = finite("gradientFeather.angle", e.angle)?; - e.start = opacity("gradientFeather.start", e.start)?; - e.end = opacity("gradientFeather.end", e.end)?; - if let Some(v) = &mut e.vector { - for c in v.iter_mut() { - *c = finite("gradientFeather.vector", *c)?; - } - } - let e = &mut self.inner_glow; - e.opacity = opacity("innerGlow.opacity", e.opacity)?; - e.size = len("innerGlow.size", e.size)?; - e.choke = pct("innerGlow.choke", e.choke)?; - let e = &mut self.bevel; - e.size = len("bevel.size", e.size)?; - e.depth = len("bevel.depth", e.depth)?; - e.angle = finite("bevel.angle", e.angle)?; - e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?; - e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?; - let e = &mut self.satin; - e.opacity = opacity("satin.opacity", e.opacity)?; - e.angle = finite("satin.angle", e.angle)?; - e.distance = len("satin.distance", e.distance)?; - e.size = len("satin.size", e.size)?; - for w in self.directional_feather.widths.iter_mut() { - *w = len("directionalFeather.widths", *w)?; - } - Ok(self) - } - - /// Any effect drawn with soft (raster) filters? - pub fn any(&self) -> bool { - self.drop_shadow.on - || self.feather > 0.0 - || self.inner_shadow.on - || self.outer_glow.on - || self.gradient_feather.on - || self.inner_glow.on - || self.bevel.on - || self.satin.on - || self.directional_feather.on - } -} - #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Item { @@ -1411,6 +104,10 @@ pub struct Item { /// Object › Object Layer Options: layers of a placed PDF that are hidden. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub pdf_hidden_layers: Vec, + /// Explicit placed-PDF visibility, including an all-on override with no hidden names. + /// False with an empty hidden list retains the file's own settings (legacy behavior). + #[serde(default, skip_serializing_if = "is_false")] + pub pdf_layer_override: bool, /// Window › Interactive › Media: options of a placed video or sound. #[serde(default, skip_serializing_if = "Option::is_none")] pub media: Option, @@ -1443,7 +140,15 @@ fn one() -> f32 { 1.0 } +fn is_false(value: &bool) -> bool { + !*value +} + impl Item { + pub fn has_pdf_layer_override(&self) -> bool { + self.pdf_layer_override || !self.pdf_hidden_layers.is_empty() + } + pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self { Item { id, @@ -1470,6 +175,7 @@ impl Item { xml_tag: String::new(), button: None, pdf_hidden_layers: Vec::new(), + pdf_layer_override: false, live_caption: None, form_field: None, export_options: ExportOptions::default(), @@ -1699,6 +405,7 @@ mod tests { #[test] fn graphics_without_a_clipping_path_load_and_save_as_before() { let g = Graphic { + layer_options: None, asset: AssetId(3), size: (40.0, 30.0), xf: Affine::translate((5.0, 5.0)), @@ -1710,6 +417,7 @@ mod tests { fill: Default::default(), }; let v = serde_json::to_value(&g).unwrap(); + assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation"); assert!(v.get("clip").is_none(), "{v}"); assert_eq!(serde_json::from_value::(v.clone()).unwrap(), g); // A clip written with only its type and path gets the default options. @@ -1721,10 +429,24 @@ mod tests { #[test] fn frame_baseline_grid_saves_as_start_then_increment() { + // A file saved before Relative To existed: the grid starts from the top of the page and + // saves back unchanged. let v = serde_json::json!({"baselineGrid": [6.0, 14.0]}); let o = serde_json::from_value::(v).unwrap(); - assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0 })); + assert_eq!(o.baseline_grid, Some(FrameBaselineGrid { start: 6.0, increment: 14.0, relative_to: FrameGridRelative::TopOfPage })); assert_eq!(serde_json::to_value(&o).unwrap()["baselineGrid"], serde_json::json!([6.0, 14.0])); + for r in [FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset] { + let o = TextFrameOptions { + baseline_grid: Some(FrameBaselineGrid { relative_to: r, ..FrameBaselineGrid::new(6.0, 14.0) }), + ..Default::default() + }; + let v = serde_json::to_value(&o).unwrap(); + assert_eq!(v["baselineGrid"], serde_json::json!([6.0, 14.0, r.name()])); + assert_eq!(serde_json::from_value::(v).unwrap().baseline_grid, o.baseline_grid); + } + for bad in [serde_json::json!([6.0]), serde_json::json!([6.0, 14.0, "frame", 1]), serde_json::json!([6.0, 14.0, "spine"])] { + assert!(serde_json::from_value::(serde_json::json!({"baselineGrid": bad})).is_err()); + } } #[test] diff --git a/crates/doc/src/item/content.rs b/crates/doc/src/item/content.rs new file mode 100644 index 000000000..b81dd266b --- /dev/null +++ b/crates/doc/src/item/content.rs @@ -0,0 +1,28 @@ +use super::graphic::Graphic; +use super::text_frame::TextFrame; +use super::{Arc, Deserialize, Item, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum Content { + #[default] + Unassigned, + Text(TextFrame), + Graphic(Graphic), + Group { + items: Vec>, + }, +} + +/// What kind of spline the item was drawn as (labels in the Layers panel, tool behaviours). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Shape { + #[default] + Rectangle, + Oval, + Polygon, + GraphicLine, + Path, + Group, +} diff --git a/crates/doc/src/item/effects.rs b/crates/doc/src/item/effects.rs new file mode 100644 index 000000000..46aac2e95 --- /dev/null +++ b/crates/doc/src/item/effects.rs @@ -0,0 +1,363 @@ +use super::{BlendMode, Deserialize, Serialize, is_default}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DropShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + /// Use Global Light (the document angle instead of `angle`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub distance: f64, + pub size: f64, + pub spread: f64, + /// How the shadow blends with what's behind it (InDesign: Multiply). A document saved before + /// effects had modes reads Normal, as they were drawn then. + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +/// The blend of an effect saved before effects had one. +fn normal_blend() -> BlendMode { + BlendMode::Normal +} + +impl Default for DropShadow { + fn default() -> Self { + DropShadow { + on: false, + mode: BlendMode::Multiply, + color: designcraft_color::swatch::BLACK.into(), + opacity: 0.75, + angle: 120.0, + distance: 7.0, + size: 5.0, + spread: 0.0, + global_light: false, + } + } +} + +/// Inner shadow: a blurred, offset shadow inside the object's edge. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerShadow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + /// Use Global Light (the document angle instead of `angle`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub distance: f64, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the edge instead of blurring it. + pub choke: f64, + /// How the shadow blends with the object (InDesign: Multiply; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +impl Default for InnerShadow { + fn default() -> Self { + InnerShadow { + on: false, + mode: BlendMode::Multiply, + color: designcraft_color::swatch::BLACK.into(), + opacity: 0.75, + angle: 120.0, + distance: 7.0, + size: 7.0, + choke: 0.0, + global_light: false, + } + } +} + +/// Outer glow: a blurred silhouette around the object, without offset. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OuterGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that grows the silhouette instead of blurring it. + pub spread: f64, + /// How the glow blends with what's behind it (InDesign: Screen; Normal when saved before). + #[serde(default = "normal_blend")] + pub mode: BlendMode, +} + +impl Default for OuterGlow { + fn default() -> Self { + // InDesign starts a glow screened in white. + OuterGlow { on: false, color: designcraft_color::swatch::PAPER.into(), opacity: 0.75, size: 7.0, spread: 0.0, mode: BlendMode::Screen } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Effects { + /// Drop shadow. `size` is the blur size; `spread` is a percentage (0–100) of it that grows + /// the silhouette instead of blurring it. + pub drop_shadow: DropShadow, + /// Basic feather width (0 = off). + pub feather: f64, + /// Basic feather's choke and corners. + #[serde(skip_serializing_if = "is_default")] + pub feather_options: FeatherOptions, + #[serde(skip_serializing_if = "is_default")] + pub inner_shadow: InnerShadow, + #[serde(skip_serializing_if = "is_default")] + pub outer_glow: OuterGlow, + #[serde(skip_serializing_if = "is_default")] + pub gradient_feather: GradientFeather, + #[serde(skip_serializing_if = "is_default")] + pub inner_glow: InnerGlow, + #[serde(skip_serializing_if = "is_default")] + pub bevel: Bevel, + #[serde(skip_serializing_if = "is_default")] + pub satin: Satin, + #[serde(skip_serializing_if = "is_default")] + pub directional_feather: DirectionalFeather, +} + +/// Basic Feather › Corners: how the fade follows the shape's corners. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FeatherCorners { + /// Mitred: the fade keeps the corners sharp. + Sharp, + /// The fade rounds the corners by its width. + Rounded, + /// From opaque to transparent, softly (InDesign's default). + #[default] + Diffused, +} + +/// Basic Feather options besides its width. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct FeatherOptions { + /// Percentage (0–100) of the width that stays opaque: the fade gets shorter. + pub choke: f64, + pub corners: FeatherCorners, +} + +/// Inner/Outer Glow › Technique. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GlowTechnique { + /// A soft, blurred glow. + #[default] + Softer, + /// A glow that keeps closer to the shape's edges. + Precise, +} + +/// Directional Feather: each side fades over its own width (item space, before rotation). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DirectionalFeather { + pub on: bool, + /// Top, left, bottom, right widths in points. + pub widths: [f64; 4], +} + +/// Inner Glow: a glow inside the edge (or from the centre). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct InnerGlow { + pub on: bool, + pub color: String, + pub opacity: f32, + pub size: f64, + /// Percentage (0–100) of `size` that hardens the glow. + pub choke: f64, + /// Glow from the centre instead of the edges. + pub center: bool, + /// How the glow blends with the object (InDesign: Screen). + pub mode: BlendMode, + pub technique: GlowTechnique, +} + +impl Default for InnerGlow { + fn default() -> Self { + InnerGlow { + on: false, + color: designcraft_color::swatch::PAPER.into(), + opacity: 0.75, + size: 7.0, + choke: 0.0, + center: false, + mode: BlendMode::Screen, + technique: GlowTechnique::Softer, + } + } +} + +/// Bevel and Emboss (inner bevel): a highlight on the lit edges, a shadow on the others. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Bevel { + pub on: bool, + pub size: f64, + /// Depth in percent (scales the highlight and shadow offset). + pub depth: f64, + pub angle: f64, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub global_light: bool, + pub highlight: String, + pub highlight_opacity: f32, + pub shadow: String, + pub shadow_opacity: f32, +} + +impl Default for Bevel { + fn default() -> Self { + Bevel { + on: false, + size: 7.0, + depth: 100.0, + angle: 120.0, + global_light: true, + highlight: designcraft_color::swatch::PAPER.into(), + highlight_opacity: 0.75, + shadow: designcraft_color::swatch::BLACK.into(), + shadow_opacity: 0.75, + } + } +} + +/// Satin: soft interior shading from two offset copies of the shape. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Satin { + pub on: bool, + pub color: String, + pub opacity: f32, + pub angle: f64, + pub distance: f64, + pub size: f64, + pub invert: bool, +} + +impl Default for Satin { + fn default() -> Self { + Satin { on: false, color: designcraft_color::swatch::BLACK.into(), opacity: 0.5, angle: 120.0, distance: 7.0, size: 7.0, invert: false } + } +} + +/// Gradient Feather: the object fades from `start` to `end` opacity along a gradient. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GradientFeather { + pub on: bool, + pub radial: bool, + /// Degrees (linear), when no vector was dragged. + pub angle: f64, + pub start: f32, + pub end: f32, + /// Gradient Feather tool vector in the item's space: start and end (linear), centre and + /// radius point (radial). + #[serde(skip_serializing_if = "Option::is_none")] + pub vector: Option<[f64; 4]>, +} + +impl Default for GradientFeather { + fn default() -> Self { + GradientFeather { on: false, radial: false, angle: 0.0, start: 1.0, end: 0.0, vector: None } + } +} + +impl GradientFeather { + /// The gradient's two points in item space for an object with inner bounds `b`. + pub fn points(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, designcraft_geom::Point) { + use designcraft_geom::{Point, Vec2}; + if let Some([x0, y0, x1, y1]) = self.vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let c = b.center(); + if self.radial { + return (c, c + Vec2::new(b.width().max(b.height()) / 2.0, 0.0)); + } + let a = self.angle.to_radians(); + let d = Vec2::new(a.cos(), -a.sin()); + let half = (b.width() * d.x.abs() + b.height() * d.y.abs()) / 2.0; + (c - d * half, c + d * half) + } +} + +impl Effects { + /// These effects with opacities in 0–1, percentages in 0–100 and sizes, widths and distances + /// not below 0, or an error naming the first value that isn't a finite number. + pub fn sanitized(mut self) -> Result { + fn finite(name: &str, v: f64) -> Result { + if v.is_finite() { Ok(v) } else { Err(format!("{name} must be a finite number")) } + } + let len = |name: &str, v: f64| finite(name, v).map(|v| v.max(0.0)); + let pct = |name: &str, v: f64| finite(name, v).map(|v| v.clamp(0.0, 100.0)); + let opacity = |name: &str, v: f32| finite(name, f64::from(v)).map(|v| v.clamp(0.0, 1.0) as f32); + let ds = &mut self.drop_shadow; + ds.opacity = opacity("dropShadow.opacity", ds.opacity)?; + ds.angle = finite("dropShadow.angle", ds.angle)?; + ds.distance = len("dropShadow.distance", ds.distance)?; + ds.size = len("dropShadow.size", ds.size)?; + ds.spread = pct("dropShadow.spread", ds.spread)?; + self.feather = len("feather", self.feather)?; + let e = &mut self.inner_shadow; + e.opacity = opacity("innerShadow.opacity", e.opacity)?; + e.angle = finite("innerShadow.angle", e.angle)?; + e.distance = len("innerShadow.distance", e.distance)?; + e.size = len("innerShadow.size", e.size)?; + e.choke = pct("innerShadow.choke", e.choke)?; + let e = &mut self.outer_glow; + e.opacity = opacity("outerGlow.opacity", e.opacity)?; + e.size = len("outerGlow.size", e.size)?; + e.spread = pct("outerGlow.spread", e.spread)?; + let e = &mut self.gradient_feather; + e.angle = finite("gradientFeather.angle", e.angle)?; + e.start = opacity("gradientFeather.start", e.start)?; + e.end = opacity("gradientFeather.end", e.end)?; + if let Some(v) = &mut e.vector { + for c in v.iter_mut() { + *c = finite("gradientFeather.vector", *c)?; + } + } + let e = &mut self.inner_glow; + e.opacity = opacity("innerGlow.opacity", e.opacity)?; + e.size = len("innerGlow.size", e.size)?; + e.choke = pct("innerGlow.choke", e.choke)?; + let e = &mut self.bevel; + e.size = len("bevel.size", e.size)?; + e.depth = len("bevel.depth", e.depth)?; + e.angle = finite("bevel.angle", e.angle)?; + e.highlight_opacity = opacity("bevel.highlightOpacity", e.highlight_opacity)?; + e.shadow_opacity = opacity("bevel.shadowOpacity", e.shadow_opacity)?; + let e = &mut self.satin; + e.opacity = opacity("satin.opacity", e.opacity)?; + e.angle = finite("satin.angle", e.angle)?; + e.distance = len("satin.distance", e.distance)?; + e.size = len("satin.size", e.size)?; + for w in self.directional_feather.widths.iter_mut() { + *w = len("directionalFeather.widths", *w)?; + } + Ok(self) + } + + /// Any effect drawn with soft (raster) filters? + pub fn any(&self) -> bool { + self.drop_shadow.on + || self.feather > 0.0 + || self.inner_shadow.on + || self.outer_glow.on + || self.gradient_feather.on + || self.inner_glow.on + || self.bevel.on + || self.satin.on + || self.directional_feather.on + } +} diff --git a/crates/doc/src/item/graphic.rs b/crates/doc/src/item/graphic.rs new file mode 100644 index 000000000..b0b3b310a --- /dev/null +++ b/crates/doc/src/item/graphic.rs @@ -0,0 +1,367 @@ +use super::content::Content; +use super::paint::Fill; +use super::text_frame::TextWrap; +use super::{Affine, Arc, AssetId, Deserialize, Item, ItemId, PathData, Rect, Serialize}; + +/// Frame fitting options (Object → Fitting → Frame Fitting Options). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Fitting { + #[default] + None, + FillProportionally, + FitProportionally, + FitContentToFrame, + CenterContent, +} + +/// Retained IDML layer choices for one placement, independent of shared asset bytes. +/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy. +/// XML Self IDs are regenerated by the exporter and are not stored here. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphicLayerOptions { + pub attributes: std::collections::BTreeMap, + pub layers: Vec>, + /// Parent indices in pre-order; absent entries are top-level layers (older native files). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parents: Vec>, +} + +pub const GRAPHIC_LAYER_RENDERING_WARNING: &str = + "Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output"; + +impl GraphicLayerOptions { + pub fn has_visibility_overrides(&self) -> bool { + self.layers.iter().any(|layer| { + layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility")) + || ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true")) + }) + } +} + +/// A placed graphic inside a frame. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Graphic { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_options: Option, + pub asset: AssetId, + /// Natural size of the graphic in points (pixels at its resolution, or PDF page size). + pub size: (f64, f64), + /// Graphic space (0,0)-(w,h) → frame inner space. + pub xf: Affine, + /// Clipping path: only the part of the graphic inside it shows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub clip: Option>, + #[serde(default)] + pub auto_fit: Fitting, + /// Frame Fitting Options › Align From: the reference point (0–8, row-major; 4 = centre). + #[serde(default = "center_ref")] + pub fit_align: u8, + /// Frame Fitting Options › Crop Amount: top, left, bottom, right (negative adds space). + #[serde(default)] + pub crop: [f64; 4], + /// Text wrap set on the graphic itself (Direct Selection), around the part its frame shows. + #[serde(default, skip_serializing_if = "TextWrap::is_default")] + pub wrap: TextWrap, + /// The swatch applied to the image itself: InDesign colours a greyscale or bitmap image with + /// it, colour images ignore it. `None` (the default, like InDesign's Black) leaves it as is. + #[serde(default, skip_serializing_if = "Fill::is_none")] + pub fill: Fill, +} + +fn center_ref() -> u8 { + 4 +} + +/// Most anchors a clipping path read from a file keeps (detected edges can be dense). +pub const MAX_CLIP_ANCHORS: usize = 100_000; + +/// Clipping Path › Type: where the clipping path came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClippingKind { + /// Traced from the image's edges against a light background. + DetectEdges, + /// Traced from one of the image's alpha channels. + AlphaChannel, + /// A path saved in the image file. + PhotoshopPath, + /// One of the above, edited by hand. + UserModifiedPath, +} + +/// A placed graphic's clipping path. The tracing options are kept as the file had them; the +/// outline itself is `path`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClippingPath { + pub kind: ClippingKind, + /// The outline in graphic space ((0,0)-(w,h) is the graphic's natural rectangle). + pub path: PathData, + /// Show the graphic outside the path instead of inside it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub invert: bool, + /// Holes inside the traced shape are part of the path. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_inside_edges: bool, + /// Detect Edges: trace only the part of the image the frame shows. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub restrict_to_frame: bool, + /// Trace the full-resolution image rather than the screen proxy. + #[serde(default = "yes")] + pub use_high_resolution: bool, + /// Detect Edges / Alpha Channel: the darkness or opacity level that counts as inside (0–255). + #[serde(default = "clip_threshold")] + pub threshold: f64, + /// How closely the traced path follows the pixels, in pixels. + #[serde(default = "clip_tolerance")] + pub tolerance: f64, + /// Inset Frame: how far the path is pulled in (points; negative pushes it out). + #[serde(default)] + pub inset: f64, + /// The alpha channel or image path the clip came from. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_name: String, + /// Index of the alpha channel (-1 = none). + #[serde(default = "no_index")] + pub source_index: i32, +} + +fn yes() -> bool { + true +} + +fn clip_threshold() -> f64 { + 25.0 +} + +fn clip_tolerance() -> f64 { + 2.0 +} + +fn no_index() -> i32 { + -1 +} + +impl ClippingPath { + /// A clipping path of `kind` with the default options. + pub fn new(kind: ClippingKind, path: PathData) -> Self { + Self { + kind, + path, + invert: false, + include_inside_edges: false, + restrict_to_frame: false, + use_high_resolution: true, + threshold: clip_threshold(), + tolerance: clip_tolerance(), + inset: 0.0, + source_name: String::new(), + source_index: no_index(), + } + } +} + +/// Liquid Layout object rules: which edges stay at their distance from the page edge, and +/// whether the object may stretch. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ObjectLiquid { + pub resize_width: bool, + pub resize_height: bool, + pub pin_top: bool, + pub pin_bottom: bool, + pub pin_left: bool, + pub pin_right: bool, +} + +/// Object Export Options: tagged PDF and EPUB/HTML handling of one object. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct ExportOptions { + /// Tagged PDF: the object is decoration (an artifact), not content. + pub artifact: bool, + /// EPUB/HTML: export the object as an image (whatever it is). + pub rasterize: bool, + /// EPUB/HTML: "left", "center" or "right" ("" = as the flow puts it). + pub align: String, + /// EPUB/HTML: start a new page (screen) before the object. + pub page_break_before: bool, +} + +impl ExportOptions { + pub fn is_default(&self) -> bool { + *self == ExportOptions::default() + } +} + +/// A PDF form field. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FormField { + pub kind: FieldKind, + /// Field name (unique in the exported PDF). + pub name: String, + /// Default value: the text, the chosen option, or "On"/"" for a check box. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub value: String, + /// Choices of a combo box or list box. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub multiline: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub required: bool, + /// Font size of the field's text (0 = auto). + #[serde(default)] + pub font_size: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FieldKind { + TextField, + CheckBox, + ComboBox, + ListBox, + Signature, +} + +/// The source and template of a live caption. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LiveCaption { + pub source: ItemId, + pub template: String, +} + +/// Media panel options for a placed video or sound. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct MediaOptions { + pub play_on_page_load: bool, + #[serde(rename = "loop")] + pub looping: bool, + /// Show the player controls. + pub controls: bool, + /// Poster image (shown on the page and in print); `None` = a placeholder frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub poster: Option, +} + +impl Item { + /// The graphic to draw for this frame: a media frame with a poster draws the poster + /// stretched over the media rectangle. + pub fn drawn_graphic(&self, assets: &std::collections::BTreeMap>) -> Option { + let Content::Graphic(g) = &self.content else { return None }; + if let Some(poster) = self.media.as_ref().and_then(|m| m.poster) + && let Some((pw, ph)) = assets.get(&poster).and_then(|a| a.pixels) + { + let (pw, ph) = (pw.max(1) as f64, ph.max(1) as f64); + return Some(Graphic { asset: poster, size: (pw, ph), xf: g.xf * Affine::scale_non_uniform(g.size.0 / pw, g.size.1 / ph), ..g.clone() }); + } + Some(g.clone()) + } +} + +/// "video" or "audio" for a media MIME type. +pub fn media_kind(mime: &str) -> Option<&'static str> { + if mime.starts_with("video/") { + Some("video") + } else if mime.starts_with("audio/") { + Some("audio") + } else { + None + } +} + +/// MIME type of a media file by its extension. +pub fn media_mime(name: &str) -> Option<&'static str> { + let ext = name.rsplit('.').next()?.to_ascii_lowercase(); + Some(match ext.as_str() { + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + "webm" => "video/webm", + "ogv" => "video/ogg", + "mp3" => "audio/mpeg", + "m4a" => "audio/mp4", + "wav" => "audio/wav", + "ogg" | "oga" => "audio/ogg", + "aac" => "audio/aac", + _ => return None, + }) +} + +/// A button's On Release action. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ButtonAction { + GoToPage { page: usize }, + GoToFirstPage, + GoToLastPage, + GoToNextPage, + GoToPreviousPage, + GoToUrl { url: String }, +} + +impl Graphic { + /// The part of the graphic its frame shows, in the frame's inner space (bounding boxes); + /// None when nothing shows. + pub fn shown_bounds(&self, frame: Rect) -> Option { + let r = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, self.size.0, self.size.1)).intersect(frame); + (r.width() > 0.0 && r.height() > 0.0).then_some(r) + } + + /// The region the clipping path lets through, in frame inner space, and whether it is filled + /// even-odd (an inverted clip: the graphic's rectangle minus the path). `None` when the graphic + /// has no clipping path or the path is empty. + pub fn clip_region(&self) -> Option<(designcraft_geom::BezPath, bool)> { + use designcraft_geom::Shape; + let c = self.clip.as_ref().filter(|c| !c.path.is_empty())?; + let mut path = c.path.to_bezpath(); + if !c.invert { + path.apply_affine(self.xf); + return Some((path, false)); + } + let outer = Rect::new(0.0, 0.0, self.size.0, self.size.1).union(path.bounding_box()).inflate(1.0, 1.0); + let mut region = outer.to_path(0.1); + region.extend(path.iter()); + region.apply_affine(self.xf); + Some((region, true)) + } + + /// The graphic transform that fits it into `frame` (the frame's inner rect) with `mode`, + /// honouring the crop amounts and the reference point. + pub fn fitted(&self, frame: Rect, mode: Fitting) -> Option { + let (nw, nh) = self.size; + if nw <= 0.0 || nh <= 0.0 { + return None; + } + let [t, l, b, r] = self.crop; + let area = Rect::new(frame.x0 - l, frame.y0 - t, frame.x1 + r, frame.y1 + b); + if area.width() <= 0.0 || area.height() <= 0.0 { + return None; + } + let (fx, fy) = ([0.0, 0.5, 1.0][(self.fit_align % 3) as usize], [0.0, 0.5, 1.0][(self.fit_align / 3).min(2) as usize]); + let place = |w: f64, h: f64| (area.x0 + (area.width() - w) * fx, area.y0 + (area.height() - h) * fy); + Some(match mode { + Fitting::None => return None, + Fitting::FillProportionally | Fitting::FitProportionally => { + let k = if mode == Fitting::FillProportionally { + (area.width() / nw).max(area.height() / nh) + } else { + (area.width() / nw).min(area.height() / nh) + }; + Affine::translate(place(nw * k, nh * k)) * Affine::scale(k) + } + Fitting::FitContentToFrame => Affine::translate((area.x0, area.y0)) * Affine::scale_non_uniform(area.width() / nw, area.height() / nh), + Fitting::CenterContent => { + let cur = self.xf.transform_rect_bbox(Rect::new(0.0, 0.0, nw, nh)); + let (x, y) = place(cur.width(), cur.height()); + Affine::translate((x - cur.x0, y - cur.y0)) * self.xf + } + }) + } +} diff --git a/crates/doc/src/item/paint.rs b/crates/doc/src/item/paint.rs new file mode 100644 index 000000000..dfbf5751a --- /dev/null +++ b/crates/doc/src/item/paint.rs @@ -0,0 +1,357 @@ +use super::{Deserialize, Point, Rect, Serialize}; + +/// Fill: a swatch reference and tint (InDesign fills always go through swatches or unnamed colours). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Fill { + pub swatch: String, + pub tint: f32, + /// Gradient angle (degrees) and length override for gradient swatches (`None` = fit to bounds). + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// Gradient Swatch tool vector in the item's own space: start and end of a linear gradient, + /// centre and radius point of a radial one (`None` = fit to bounds at `gradient_angle`). + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, + pub overprint: bool, +} + +impl Default for Fill { + fn default() -> Self { + Fill::none() + } +} + +impl Fill { + /// A radial gradient's ellipse on an object whose bounds (in its own space) are `b`: centre and + /// horizontal and vertical radii. As in InDesign, a radial gradient stretches with the object's + /// box: by default it's centred and reaches the box's sides (an ellipse in a non-square box); + /// with a Gradient Swatch tool vector it's centred at the vector's start, and the vector's + /// length, measured with the box made square, sets its size. + pub fn radial_ellipse(&self, b: designcraft_geom::Rect) -> (designcraft_geom::Point, f64, f64) { + let k = if b.width() > 1e-9 && b.height() > 1e-9 { b.height() / b.width() } else { 1.0 }; + match self.gradient_vector { + Some([x0, y0, x1, y1]) => { + let r = designcraft_geom::Vec2::new(x1 - x0, (y1 - y0) / k).hypot(); + (designcraft_geom::Point::new(x0, y0), r, r * k) + } + // A box with no width or height (a straight line's) has no ellipse: a circle across + // its length. + None if b.width() <= 1e-9 || b.height() <= 1e-9 => { + let r = b.width().max(b.height()).max(1e-3) / 2.0; + (b.center(), r, r) + } + None => (b.center(), b.width() / 2.0, b.height() / 2.0), + } + } + + /// A linear gradient's axis on an object whose bounds (in its own space) are `b`: the + /// Gradient Swatch tool's vector, or the box's extent along the gradient's angle through its + /// centre. A box with no extent along that angle (a straight line, across it) takes its longer + /// side. + pub fn linear_axis(&self, b: Rect) -> (Point, Point) { + if let Some([x0, y0, x1, y1]) = self.gradient_vector { + return (Point::new(x0, y0), Point::new(x1, y1)); + } + let a = self.gradient_angle.unwrap_or(0.0).to_radians(); + let half = (b.width() * a.cos().abs() + b.height() * a.sin().abs()) / 2.0; + let half = if half > 1e-9 { half } else { b.width().max(b.height()).max(1e-3) / 2.0 }; + let d = designcraft_geom::Vec2::new(a.cos(), -a.sin()) * half; + (b.center() - d, b.center() + d) + } + + pub fn none() -> Self { + Fill { swatch: designcraft_color::swatch::NONE.into(), tint: 1.0, gradient_angle: None, gradient_vector: None, overprint: false } + } + pub fn swatch(name: &str) -> Self { + Fill { swatch: name.into(), ..Fill::none() } + } + pub fn is_none(&self) -> bool { + self.swatch == designcraft_color::swatch::NONE + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StrokeAlign { + #[default] + Center, + Inside, + Outside, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Cap { + #[default] + Butt, + Round, + Projecting, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Join { + #[default] + Miter, + Round, + Bevel, +} + +/// The shortest non-zero dash or gap a command accepts, in points. +pub const MIN_DASH: f64 = 0.1; + +/// The longest dash or gap a command accepts, in points. +pub const MAX_DASH: f64 = 10_000.0; + +/// The most lengths a dash pattern holds. +pub const MAX_DASH_ENTRIES: usize = 64; + +/// The most pattern repeats a renderer expands along one path; finer patterns draw solid. +pub const MAX_DASH_PERIODS: f64 = 20_000.0; + +/// An upper bound of the length of `bp`: the length of its control polygon. +pub fn path_length_bound(bp: &designcraft_geom::BezPath) -> f64 { + use designcraft_geom::PathSeg; + bp.segments() + .map(|s| match s { + PathSeg::Line(l) => l.p0.distance(l.p1), + PathSeg::Quad(q) => q.p0.distance(q.p1) + q.p1.distance(q.p2), + PathSeg::Cubic(c) => c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3), + }) + .sum() +} + +/// Stroke types (Stroke panel → Type). Dash/gap arrays are in points. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum StrokeType { + #[default] + Solid, + Dashed { + pattern: Vec, + }, + Dotted, + ThickThin, + ThinThick, + ThinThin, + ThickThick, + ThinThickThin, + ThickThinThick, + Wavy, + Hashed, + /// A custom striped stroke: bands (start, width) as fractions of the weight from the left edge. + Stripes { + bands: Vec<(f64, f64)>, + }, + /// A named stroke style of the document (Stroke Styles), by name. + Style { + name: String, + }, +} + +impl StrokeType { + pub fn label(&self) -> &'static str { + match self { + StrokeType::Solid => "Solid", + StrokeType::Dashed { .. } => "Dashed", + StrokeType::Dotted => "Dotted", + StrokeType::ThickThin => "Thick - Thin", + StrokeType::ThinThick => "Thin - Thick", + StrokeType::ThinThin => "Thin - Thin", + StrokeType::ThickThick => "Thick - Thick", + StrokeType::ThinThickThin => "Thin - Thick - Thin", + StrokeType::ThickThinThick => "Thick - Thin - Thick", + StrokeType::Wavy => "Wavy", + StrokeType::Hashed => "Straight Hash", + StrokeType::Stripes { .. } => "Stripes", + StrokeType::Style { .. } => "Custom", + } + } + + /// Whether `pattern` is a dash pattern commands accept: 1 to [`MAX_DASH_ENTRIES`] lengths, + /// each 0 or from [`MIN_DASH`] to [`MAX_DASH`] points, not all 0. + pub fn valid_dash_pattern(pattern: &[f64]) -> bool { + !pattern.is_empty() + && pattern.len() <= MAX_DASH_ENTRIES + && pattern.iter().all(|x| *x == 0.0 || (MIN_DASH..=MAX_DASH).contains(x)) + && pattern.iter().any(|x| *x > 0.0) + } + + /// Whether a renderer may expand `pattern` along a path at most `length` points long: at + /// most [`MAX_DASH_ENTRIES`] finite, non-negative lengths that repeat at most + /// [`MAX_DASH_PERIODS`] times. Otherwise the stroke draws solid, so a degenerate pattern + /// from a file can't stall rendering. + pub fn expandable_dashes(pattern: &[f64], length: f64) -> bool { + let period: f64 = pattern.iter().sum(); + pattern.len() <= MAX_DASH_ENTRIES + && pattern.iter().all(|x| x.is_finite() && *x >= 0.0) + && period > 0.0 + && length.is_finite() + && length / period <= MAX_DASH_PERIODS + } + + /// The bands of a striped type (start, width as fractions of the weight). + pub fn bands(&self) -> Option> { + Some(match self { + StrokeType::ThickThin => vec![(0.0, 0.5), (0.75, 0.25)], + StrokeType::ThinThick => vec![(0.0, 0.25), (0.5, 0.5)], + StrokeType::ThinThin => vec![(0.0, 1.0 / 3.0), (2.0 / 3.0, 1.0 / 3.0)], + StrokeType::ThickThick => vec![(0.0, 0.4), (0.6, 0.4)], + StrokeType::ThinThickThin => vec![(0.0, 0.2), (0.35, 0.3), (0.8, 0.2)], + StrokeType::ThickThinThick => vec![(0.0, 0.35), (0.45, 0.1), (0.65, 0.35)], + StrokeType::Stripes { bands } => bands.clone(), + _ => return None, + }) + } + + /// The filled area for types drawn as fills (stripes, wavy, hash); `None` for solid, dashed + /// and dotted strokes (and unresolved named styles). + pub fn outline(&self, bp: &designcraft_geom::BezPath, weight: f64, tol: f64) -> Option { + use designcraft_geom::stroke_style as ss; + match self { + StrokeType::Wavy => Some(ss::wavy(bp, weight, tol)), + StrokeType::Hashed => Some(ss::hashed(bp, weight, tol)), + t => t.bands().map(|b| ss::stripes(bp, weight, &b, tol)), + } + } +} + +/// A named custom stroke style (Stroke Styles dialog): dashes, dots or stripes. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StrokeStyleDef { + pub name: String, + pub kind: StrokeType, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Arrowhead { + #[default] + None, + SimpleWide, + Simple, + Triangle, + TriangleWide, + Barbed, + Curved, + Circle, + CircleSolid, + Square, + SquareSolid, + Bar, +} + +impl Arrowhead { + pub const ALL: [Arrowhead; 12] = [ + Arrowhead::None, + Arrowhead::Simple, + Arrowhead::SimpleWide, + Arrowhead::Triangle, + Arrowhead::TriangleWide, + Arrowhead::Barbed, + Arrowhead::Curved, + Arrowhead::Circle, + Arrowhead::CircleSolid, + Arrowhead::Square, + Arrowhead::SquareSolid, + Arrowhead::Bar, + ]; + + pub fn label(self) -> &'static str { + match self { + Arrowhead::None => "None", + Arrowhead::Simple => "Simple", + Arrowhead::SimpleWide => "Simple - Wide", + Arrowhead::Triangle => "Triangle", + Arrowhead::TriangleWide => "Triangle - Wide", + Arrowhead::Barbed => "Barbed", + Arrowhead::Curved => "Curved", + Arrowhead::Circle => "Circle", + Arrowhead::CircleSolid => "Circle - Solid", + Arrowhead::Square => "Square", + Arrowhead::SquareSolid => "Square - Solid", + Arrowhead::Bar => "Bar", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Stroke { + pub swatch: String, + pub tint: f32, + pub weight: f64, + #[serde(rename = "type")] + pub kind: StrokeType, + pub align: StrokeAlign, + pub cap: Cap, + pub join: Join, + pub miter_limit: f64, + pub start: Arrowhead, + pub end: Arrowhead, + /// Gap colour for dashed/striped strokes. + pub gap_swatch: String, + pub gap_tint: f32, + pub overprint: bool, + /// Overprint Gap (Attributes panel). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub gap_overprint: bool, + /// A gradient swatch's angle on the stroke, as [`Fill::gradient_angle`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_angle: Option, + /// The Gradient Swatch tool's vector on the stroke, as [`Fill::gradient_vector`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub gradient_vector: Option<[f64; 4]>, +} + +impl Default for Stroke { + /// InDesign's default for drawn shapes: 1 pt black. + fn default() -> Self { + Stroke { + swatch: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + weight: 1.0, + kind: StrokeType::Solid, + align: StrokeAlign::Center, + cap: Cap::Butt, + join: Join::Miter, + miter_limit: 4.0, + start: Arrowhead::None, + end: Arrowhead::None, + gap_swatch: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + overprint: false, + gap_overprint: false, + gradient_angle: None, + gradient_vector: None, + } + } +} + +impl Stroke { + /// The stroke's paint (swatch, tint and gradient geometry) as a fill, to paint it the same way: + /// a gradient swatch on a stroke runs across the object's box as on its fill. + pub fn as_fill(&self) -> Fill { + Fill { + swatch: self.swatch.clone(), + tint: self.tint, + gradient_angle: self.gradient_angle, + gradient_vector: self.gradient_vector, + overprint: self.overprint, + } + } + + /// How far the painted stroke can reach beyond the path (arrowheads reach further). + pub fn extent(&self) -> f64 { + if self.start == Arrowhead::None && self.end == Arrowhead::None { self.weight } else { self.weight.max(0.5) * 5.5 } + } + + pub fn none() -> Self { + Stroke { swatch: designcraft_color::swatch::NONE.into(), ..Stroke::default() } + } + pub fn is_none(&self) -> bool { + self.swatch == designcraft_color::swatch::NONE || self.weight <= 0.0 + } +} diff --git a/crates/doc/src/item/text_frame.rs b/crates/doc/src/item/text_frame.rs new file mode 100644 index 000000000..d8f7f29e3 --- /dev/null +++ b/crates/doc/src/item/text_frame.rs @@ -0,0 +1,321 @@ +use super::{Affine, Deserialize, PathData, Rect, Serialize, StoryId, is_default}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum VerticalJustification { + #[default] + Top, + Center, + Bottom, + Justify, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FirstBaseline { + #[default] + Ascent, + CapHeight, + Leading, + XHeight, + Fixed, + /// The top of the font's em box: its `BASE` em box; else, for a CJK font, its OS/2 typo + /// ascender when the typo ascender and descender span one em; else half an em above half the + /// cap height. + EmboxHeight, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AutoSize { + #[default] + Off, + HeightOnly, + WidthOnly, + HeightAndWidth, + HeightAndWidthProportionally, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ColumnsKind { + #[default] + FixedNumber, + FixedWidth, + FlexibleWidth, +} + +/// Text Frame Options. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TextFrameOptions { + pub columns: u32, + pub columns_kind: ColumnsKind, + pub gutter: f64, + /// Column width for fixed/flexible width columns. + pub column_width: f64, + pub balance_columns: bool, + /// Inset spacing: top, left, bottom, right. + pub inset: [f64; 4], + pub vertical_justification: VerticalJustification, + pub vj_paragraph_spacing_limit: f64, + pub first_baseline: FirstBaseline, + pub first_baseline_min: f64, + pub ignore_wrap: bool, + pub auto_size: AutoSize, + /// Reference point (0..9, row-major from the top left) that stays fixed when auto-sizing. + pub auto_size_ref: u8, + /// Auto-size never makes the frame shorter than `auto_size_min_height` while this is on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_use_min_height: bool, + #[serde(skip_serializing_if = "is_default")] + pub auto_size_min_height: f64, + /// Auto-size never makes the frame narrower than `auto_size_min_width` while this is on. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_use_min_width: bool, + #[serde(skip_serializing_if = "is_default")] + pub auto_size_min_width: f64, + /// Width-growing auto-size sets each paragraph on one line (the frame widens to the longest). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub auto_size_no_line_breaks: bool, + pub column_rule: bool, + pub column_rule_weight: f64, + pub column_rule_color: String, + /// 0..1. + pub column_rule_tint: f32, + /// Horizontal offset of each rule from the middle of its gutter. + pub column_rule_offset: f64, + /// The rule starts this far below the column top and ends this far above its bottom. + pub column_rule_top_inset: f64, + pub column_rule_bottom_inset: f64, + /// Use a custom baseline grid for this frame. + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_grid: Option, + /// Type on a Path: the text runs along the item's path instead of filling it. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// A text frame's custom baseline grid. Saved as `[start, increment]` when it starts from the +/// top of the page, otherwise as `[start, increment, relativeTo]`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameBaselineGrid { + /// The first grid line, down from the line `relative_to` names. + pub start: f64, + /// The distance between grid lines; 0 or less turns the grid off. + pub increment: f64, + pub relative_to: FrameGridRelative, +} + +impl FrameBaselineGrid { + /// A grid measured from the top of the page. + pub fn new(start: f64, increment: f64) -> Self { + FrameBaselineGrid { start, increment, relative_to: FrameGridRelative::TopOfPage } + } +} + +/// What a frame's custom baseline grid's start is measured from. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FrameGridRelative { + #[default] + #[serde(rename = "page")] + TopOfPage, + #[serde(rename = "margin")] + TopMargin, + #[serde(rename = "frame")] + TopOfFrame, + #[serde(rename = "inset")] + TopInset, +} + +impl FrameGridRelative { + pub const ALL: [FrameGridRelative; 4] = + [FrameGridRelative::TopOfPage, FrameGridRelative::TopMargin, FrameGridRelative::TopOfFrame, FrameGridRelative::TopInset]; + + /// The name commands and native files use: page, margin, frame or inset. + pub fn name(self) -> &'static str { + match self { + FrameGridRelative::TopOfPage => "page", + FrameGridRelative::TopMargin => "margin", + FrameGridRelative::TopOfFrame => "frame", + FrameGridRelative::TopInset => "inset", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|r| r.name() == name) + } +} + +impl Serialize for FrameBaselineGrid { + fn serialize(&self, s: S) -> Result { + // Files saved before Relative To existed hold two elements; a page-relative grid keeps that form. + match self.relative_to { + FrameGridRelative::TopOfPage => (self.start, self.increment).serialize(s), + r => (self.start, self.increment, r).serialize(s), + } + } +} + +impl<'de> Deserialize<'de> for FrameBaselineGrid { + fn deserialize>(d: D) -> Result { + struct Grid; + impl<'de> serde::de::Visitor<'de> for Grid { + type Value = FrameBaselineGrid; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("[start, increment] or [start, increment, relativeTo]") + } + fn visit_seq>(self, mut a: A) -> Result { + use serde::de::Error; + let start = a.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let increment = a.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let relative_to = a.next_element()?.unwrap_or_default(); + if a.next_element::()?.is_some() { + return Err(A::Error::invalid_length(4, &self)); + } + Ok(FrameBaselineGrid { start, increment, relative_to }) + } + } + d.deserialize_seq(Grid) + } +} + +/// Type on a Path Options. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct PathType { + /// Where the text starts, as a distance along the path (the start bracket). + pub start: f64, + /// Where the text ends, as a distance along the path (the end bracket); `None` is the path's + /// end. Text past it is overset. + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + /// Run the other way (the text sits on the other side). + pub flip: bool, + pub align: PathAlign, +} + +/// Which part of the type sits on the path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathAlign { + #[default] + Baseline, + Ascender, + Descender, + Center, +} + +impl Default for TextFrameOptions { + fn default() -> Self { + TextFrameOptions { + columns: 1, + columns_kind: ColumnsKind::FixedNumber, + gutter: 12.0, + column_width: 0.0, + balance_columns: false, + inset: [0.0; 4], + vertical_justification: VerticalJustification::Top, + vj_paragraph_spacing_limit: 0.0, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + ignore_wrap: false, + auto_size: AutoSize::Off, + auto_size_ref: 4, + auto_size_use_min_height: false, + auto_size_min_height: 0.0, + auto_size_use_min_width: false, + auto_size_min_width: 0.0, + auto_size_no_line_breaks: false, + column_rule: false, + column_rule_weight: 1.0, + column_rule_color: designcraft_color::swatch::BLACK.into(), + column_rule_tint: 1.0, + column_rule_offset: 0.0, + column_rule_top_inset: 0.0, + column_rule_bottom_inset: 0.0, + baseline_grid: None, + path: None, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WrapMode { + #[default] + None, + BoundingBox, + Contour, + JumpObject, + JumpToNextColumn, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WrapSide { + #[default] + BothSides, + RightSide, + LeftSide, + TowardsSpine, + AwayFromSpine, + LargestArea, +} + +/// Text Wrap panel › Contour Options › Type: the outline a contour wrap follows on a graphic frame. +/// Other frames always wrap around their own path. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ContourType { + /// The placed graphic's rectangle (within the frame). + BoundingBox, + /// Edges detected in the image. Composition follows the frame path. + DetectEdges, + /// The image's alpha channel. Composition follows the frame path. + AlphaChannel, + /// A path saved in the image. Composition follows the frame path. + PhotoshopPath, + /// The frame's path. + GraphicFrame, + /// The graphic's clipping path, or the frame's path when it has none. + #[default] + SameAsClipping, +} + +/// Text Wrap panel settings. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TextWrap { + pub mode: WrapMode, + /// Top, left, bottom, right offsets. + pub offsets: [f64; 4], + pub invert: bool, + pub side: WrapSide, + pub contour: ContourType, + /// A custom wrap boundary in the item's inner space, offsets already included. Bounding-box + /// and contour wraps follow it instead of the frame; other modes use its bounds. Resizing the + /// item transforms it with the path; other path edits keep it as it is. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl TextWrap { + pub fn is_default(&self) -> bool { + *self == TextWrap::default() + } +} + +/// Vertical text: composed in a box `area.height()` wide and `area.width()` tall at the origin, +/// placed so lines go down and follow each other leftwards (a clockwise quarter turn). +pub fn vertical_text_xf(area: Rect) -> Affine { + Affine::new([0.0, 1.0, -1.0, 0.0, area.x1, area.y0]) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TextFrame { + pub story: StoryId, + #[serde(default)] + pub options: TextFrameOptions, +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a509fdd8b..bf9998209 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -20,6 +20,7 @@ pub mod cjk_settings; pub mod datamerge; mod edit; pub mod endnotes; +pub mod file_info; pub mod flex; pub mod ids; pub mod index; @@ -53,6 +54,7 @@ pub use designcraft_color as color; pub use designcraft_geom as geom; pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit}; pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK}; +pub use file_info::FileInfo; pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize}; pub use ids::*; pub use index::{INDEX_MARK, IndexRef}; @@ -237,7 +239,8 @@ impl Document { } } -/// Ink Manager settings for output. +/// Ink Manager settings for output, by swatch name. Output applies them through a +/// `craft_prepress::InkSet` built from the document (`designcraft_pdf::inks`). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct InkManager { @@ -253,19 +256,6 @@ impl InkManager { fn is_default(&self) -> bool { *self == Self::default() } - - /// The ink a spot swatch prints on (after aliases; at most a few hops) and whether it's - /// converted to process. - pub fn resolve<'a>(&'a self, ink: &'a str) -> (&'a str, bool) { - let mut n = ink; - for _ in 0..8 { - match self.aliases.iter().find(|(a, _)| a == n) { - Some((_, to)) if to != n => n = to, - _ => break, - } - } - (n, self.all_to_process || self.to_process.iter().any(|x| x == n)) - } } /// XML tags and mappings. @@ -343,6 +333,26 @@ pub enum ChapterSource { SameAsPrevious, } +/// EPUB Export Options › Split Document: where a reflowable EPUB starts a new content file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EpubSplit { + /// One content file. + #[default] + Off, + /// Single Paragraph Style: a file starts before each paragraph of the named style. + Style(String), + /// Based on Paragraph Style Export Tags: a file starts before each paragraph of a style whose + /// Export Tagging has Split Document on. + ExportTags, +} + +impl EpubSplit { + pub fn is_off(&self) -> bool { + *self == EpubSplit::Off + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct DocSettings { @@ -417,6 +427,9 @@ pub struct DocSettings { /// The fill and stroke new shapes are drawn with: what the Tools panel's proxy shows and sets /// with nothing selected (InDesign's document defaults). pub new_object: NewObjectColors, + /// EPUB Export Options › Split Document. + #[serde(skip_serializing_if = "EpubSplit::is_off")] + pub epub_split: EpubSplit, } fn is_true(b: &bool) -> bool { @@ -482,6 +495,7 @@ impl Default for DocSettings { zero_point_locked: false, column_guides_locked: true, new_object: NewObjectColors::default(), + epub_split: EpubSplit::Off, } } } @@ -640,6 +654,9 @@ fn is_zero(v: &u32) -> bool { #[serde(rename_all = "camelCase")] pub struct Document { pub title: String, + /// File › File Info. + #[serde(default, skip_serializing_if = "FileInfo::is_empty")] + pub file_info: FileInfo, pub settings: DocSettings, pub spreads: Vec>, pub parents: Vec>, diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index c4d83969a..0811c549c 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -262,7 +262,7 @@ impl crate::Document { let Some(me) = self.story(sid) else { return o.start_at }; let mine = key(me); let before: usize = self.stories.values().filter(|st| st.id != sid && !st.notes.is_empty() && key(st) < mine).map(|st| st.notes.len()).sum(); - o.start_at + before as u32 + o.start_at.saturating_add(before as u32) } /// Absolute page an item sits on (its center), None on parent pages / pasteboard-only spreads. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index e3a0e39ee..05adcb35f 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -125,6 +125,9 @@ pub struct Story { /// right to left, in every frame of the thread. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub vertical: bool, + /// Story ▸ Optical Margin Alignment. + #[serde(default, skip_serializing_if = "OpticalMargin::is_default")] + pub optical_margin: OpticalMargin, /// Bumped on every edit (composition cache key). #[serde(default)] pub rev: u64, @@ -160,6 +163,45 @@ pub struct Story { pub file_link: Option, } +/// Story ▸ Optical Margin Alignment: punctuation and the edges of some letters hang outside the +/// frame's edges, in every paragraph of the story except those with Ignore Optical Margin. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct OpticalMargin { + pub on: bool, + /// The type size (points) the hang amounts are set for: text of this size hangs by the + /// built-in proportions of each character's advance, and the hang grows with this size. + pub size: f64, +} + +impl Default for OpticalMargin { + fn default() -> Self { + OpticalMargin { on: false, size: Self::DEFAULT_SIZE } + } +} + +impl OpticalMargin { + pub const DEFAULT_SIZE: f64 = 12.0; + /// InDesign's range for the size. + pub const MIN_SIZE: f64 = 0.1; + pub const MAX_SIZE: f64 = 1296.0; + + /// `v` clamped to the size range, or `None` when it isn't a finite number. + pub fn clamp_size(v: f64) -> Option { + v.is_finite().then(|| v.clamp(Self::MIN_SIZE, Self::MAX_SIZE)) + } + + /// The size the story's text hangs for, when the setting is on (a stored size outside the + /// range is clamped, a non-finite one reads as the default). + pub fn hang_size(&self) -> Option { + self.on.then(|| Self::clamp_size(self.size).unwrap_or(Self::DEFAULT_SIZE)) + } + + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + /// A linked text file: where a placed story came from, what the file held, and the Word Import /// Options that brought it in, so Update Link can import it again the same way. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -201,6 +243,7 @@ impl Story { frames: vec![], direction: crate::TextDirection::LeftToRight, vertical: false, + optical_margin: OpticalMargin::default(), rev: 0, tables: BTreeMap::new(), notes: Vec::new(), @@ -596,6 +639,22 @@ mod tests { Story::with_text(StoryId(1), t, ParaFormat::default()) } + #[test] + fn optical_margin_is_optional_in_saved_stories() { + let plain = serde_json::to_value(st("Text")).unwrap(); + assert!(plain.get("opticalMargin").is_none()); + let old: Story = serde_json::from_value(plain).unwrap(); + assert_eq!(old.optical_margin, OpticalMargin { on: false, size: 12.0 }); + let mut s = st("Text"); + s.optical_margin = OpticalMargin { on: true, size: 18.0 }; + let back: Story = serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap(); + assert_eq!(back.optical_margin, s.optical_margin); + // A hostile stored size is clamped where it is used. + assert_eq!(OpticalMargin { on: true, size: 1e9 }.hang_size(), Some(OpticalMargin::MAX_SIZE)); + assert_eq!(OpticalMargin { on: true, size: -3.0 }.hang_size(), Some(OpticalMargin::MIN_SIZE)); + assert_eq!(OpticalMargin { on: false, size: 18.0 }.hang_size(), None); + } + #[test] fn paragraphs_track_newlines() { let mut s = st("one\ntwo"); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 5c747fdbf..7605cc447 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -113,6 +113,10 @@ pub struct ExportTag { pub tag: String, /// CSS class (empty = from the style name). pub class: String, + /// Split Document (EPUB only): an EPUB content file starts before each paragraph of the + /// style, when the document's EPUB split is based on export tags (`EpubSplit::ExportTags`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub split: bool, } impl Styles { diff --git a/crates/doc/src/swatch_refs.rs b/crates/doc/src/swatch_refs.rs index d15519fcf..1e79662e9 100644 --- a/crates/doc/src/swatch_refs.rs +++ b/crates/doc/src/swatch_refs.rs @@ -17,6 +17,53 @@ struct Names<'a> { } impl Names<'_> { + /// Rename the swatch references in every paragraph, character, object, cell and table style. + fn styles(&self, styles: &mut crate::styles::Styles) { + let n = self; + for p in &mut styles.paragraph { + n.para(&mut p.para); + n.chars(&mut p.chars); + } + for c in &mut styles.character { + n.chars(&mut c.chars); + } + for o in &mut styles.object { + if let Some(f) = &mut o.fill { + n.fill(f); + } + if let Some(s) = &mut o.stroke { + n.stroke(s); + } + if let Some(tf) = &mut o.text_frame { + n.frame(tf); + } + } + for c in &mut styles.cell { + n.o(&mut c.fill); + if let Some(s) = &mut c.stroke { + n.cell_stroke(s); + } + for s in &mut c.strokes { + n.stroke_attrs(s); + } + } + for t in &mut styles.table { + if let Some(b) = &mut t.border { + n.cell_stroke(b); + } + for b in &mut t.borders { + n.stroke_attrs(b); + } + if let Some(f) = &mut t.alt_rows { + n.alt_fills(f); + } + n.alt_fill_attrs(&mut t.row_fills); + n.alt_fill_attrs(&mut t.column_fills); + n.alt_stroke_attrs(&mut t.row_strokes); + n.alt_stroke_attrs(&mut t.column_strokes); + } + } + fn s(&self, v: &mut String) { if v == self.from { *v = self.to.to_string(); @@ -142,9 +189,50 @@ impl Names<'_> { } impl Document { + /// Give gradient stops that name a color swatch that swatch's current color, after a + /// swatch edit. Stops whose swatch is gone or isn't a color keep the color they have. + pub fn refresh_gradient_stops(&mut self) { + let mut fresh: Vec<(usize, usize, designcraft_color::Color)> = Vec::new(); + for (wi, w) in self.swatches.iter().enumerate() { + let SwatchValue::Gradient { gradient } = &w.value else { continue }; + for (si, stop) in gradient.stops.iter().enumerate() { + let Some(name) = &stop.swatch else { continue }; + if designcraft_color::swatch::resolve_gradient(&self.swatches, name).is_some() { + continue; + } + if let Some(c) = self.resolve_color(name, 1.0) + && c != stop.color + { + fresh.push((wi, si, c)); + } + } + } + for (wi, si, c) in fresh { + if let Some(SwatchValue::Gradient { gradient }) = self.swatches.get_mut(wi).map(|w| &mut w.value) + && let Some(stop) = gradient.stops.get_mut(si) + { + stop.color = c; + } + } + } + + /// Turn gradient stops that name swatch `name` into plain colors (the swatch is being + /// deleted); each keeps the color it shows. + pub fn forget_gradient_stop_swatch(&mut self, name: &str) { + for w in &mut self.swatches { + let SwatchValue::Gradient { gradient } = &mut w.value else { continue }; + for stop in &mut gradient.stops { + if stop.swatch.as_deref() == Some(name) { + stop.swatch = None; + } + } + } + } + /// Point every reference to swatch `from` at `to`: tints based on it, colour groups, inks, /// styles, page items (fills, strokes, gaps, effects, column rules), text, kenten and ruby /// colours, rules, shading, borders and border gaps, tables (with stroke gaps), footnotes, + /// gradient stops, /// endnotes and the footnote rule. The swatch itself is renamed by the /// caller. pub fn rename_swatch_refs(&mut self, from: &str, to: &str) { @@ -153,8 +241,14 @@ impl Document { } let n = Names { from, to }; for w in &mut self.swatches { - if let SwatchValue::Tint { base, .. } = &mut w.value { - n.s(base); + match &mut w.value { + SwatchValue::Tint { base, .. } => n.s(base), + SwatchValue::Gradient { gradient } => { + for stop in &mut gradient.stops { + n.o(&mut stop.swatch); + } + } + _ => {} } } for g in &mut self.color_groups { @@ -170,49 +264,7 @@ impl Document { n.s(b); } n.s(&mut self.footnote_options.rule.color); - let styles = self.styles_mut(); - for p in &mut styles.paragraph { - n.para(&mut p.para); - n.chars(&mut p.chars); - } - for c in &mut styles.character { - n.chars(&mut c.chars); - } - for o in &mut styles.object { - if let Some(f) = &mut o.fill { - n.fill(f); - } - if let Some(s) = &mut o.stroke { - n.stroke(s); - } - if let Some(tf) = &mut o.text_frame { - n.frame(tf); - } - } - for c in &mut styles.cell { - n.o(&mut c.fill); - if let Some(s) = &mut c.stroke { - n.cell_stroke(s); - } - for s in &mut c.strokes { - n.stroke_attrs(s); - } - } - for t in &mut styles.table { - if let Some(b) = &mut t.border { - n.cell_stroke(b); - } - for b in &mut t.borders { - n.stroke_attrs(b); - } - if let Some(f) = &mut t.alt_rows { - n.alt_fills(f); - } - n.alt_fill_attrs(&mut t.row_fills); - n.alt_fill_attrs(&mut t.column_fills); - n.alt_stroke_attrs(&mut t.row_strokes); - n.alt_stroke_attrs(&mut t.column_strokes); - } + n.styles(self.styles_mut()); for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) { for it in &mut Arc::make_mut(sp).items { n.item(Arc::make_mut(it), 0); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d80b1d475..d5342b5c9 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -16,345 +16,14 @@ use crate::ids::StoryId; use crate::item::{FirstBaseline, StrokeType, VerticalJustification}; use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RowHeightMode { - /// Grows with the content (the height is a minimum). - #[default] - AtLeast, - Exactly, -} +mod cell; +mod edit; +mod grid; +mod merge; +mod story; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum RowKind { - Header, - #[default] - Body, - Footer, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct Row { - pub height: f64, - pub mode: RowHeightMode, - pub kind: RowKind, -} - -impl Default for Row { - fn default() -> Self { - Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Column { - pub width: f64, -} - -/// One cell edge or the table border. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct CellStroke { - pub weight: f64, - pub color: String, - pub tint: f32, - pub kind: StrokeType, - /// Painted in the gaps of dashed, dotted and striped types. - #[serde(skip_serializing_if = "is_no_swatch")] - pub gap_color: String, - #[serde(skip_serializing_if = "is_full")] - pub gap_tint: f32, -} - -fn is_no_swatch(s: &str) -> bool { - s == designcraft_color::swatch::NONE -} -fn is_full(t: &f32) -> bool { - *t == 1.0 -} - -impl Default for CellStroke { - fn default() -> Self { - CellStroke { - weight: 1.0, - color: designcraft_color::swatch::BLACK.into(), - tint: 1.0, - kind: StrokeType::Solid, - gap_color: designcraft_color::swatch::NONE.into(), - gap_tint: 1.0, - } - } -} - -impl CellStroke { - pub fn none() -> Self { - CellStroke { weight: 0.0, ..Default::default() } - } - pub fn is_visible(&self) -> bool { - self.weight > 0.0 && self.color != designcraft_color::swatch::NONE - } -} - -/// Table-level alternating row/column strokes. Retained independently of cell formatting -/// so an explicit cell style or local edge continues to take precedence. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AltStrokes { - pub first: u32, - pub next: u32, - pub first_stroke: CellStroke, - pub next_stroke: CellStroke, - pub skip_first: u32, - pub skip_last: u32, -} - -impl Default for AltStrokes { - fn default() -> Self { - Self { - first: 0, - next: 0, - first_stroke: CellStroke::default(), - next_stroke: CellStroke { weight: 0.25, ..Default::default() }, - skip_first: 0, - skip_last: 0, - } - } -} - -impl AltStrokes { - /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal - /// patterns and skip settings are preserved for round trips until their boundary - /// phase is supported; they must not be rendered with a guessed phase. - pub fn uniform_stroke(&self) -> Option<&CellStroke> { - if self.skip_first > 0 || self.skip_last > 0 { - return None; - } - match (self.first > 0, self.next > 0) { - (true, false) => Some(&self.first_stroke), - (false, true) => Some(&self.next_stroke), - (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { - Some(&self.first_stroke) - } - _ => None, - } - } -} - -fn no_flags(flags: &[bool; 4]) -> bool { - !flags.iter().any(|v| *v) -} -fn no_priorities(priorities: &[i32; 4]) -> bool { - priorities.iter().all(|v| *v == 0) -} - -fn one() -> u32 { - 1 -} -fn full() -> f32 { - 1.0 -} -fn no_fill() -> String { - designcraft_color::swatch::NONE.into() -} -fn is_ascent(f: &FirstBaseline) -> bool { - *f == FirstBaseline::Ascent -} -fn is_zero_min(v: &f64) -> bool { - *v == 0.0 -} -fn default_insets() -> [f64; 4] { - [4.0; 4] -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Cell { - #[serde(default = "one")] - pub row_span: u32, - #[serde(default = "one")] - pub col_span: u32, - /// Cell content (a frameless story). - pub text: Story, - #[serde(default = "no_fill")] - pub fill: String, - #[serde(default = "full")] - pub fill_tint: f32, - /// Top, left, bottom, right. - #[serde(default = "default_insets")] - pub insets: [f64; 4], - #[serde(default)] - pub vj: VerticalJustification, - /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the - /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). - #[serde(default, skip_serializing_if = "is_ascent")] - pub first_baseline: FirstBaseline, - #[serde(default, skip_serializing_if = "is_zero_min")] - pub first_baseline_min: f64, - /// Text rotation in degrees (only 0 is composed for now). - #[serde(default)] - pub rotation: f64, - /// Edge strokes: top, left, bottom, right. - #[serde(default)] - pub strokes: [CellStroke; 4], - /// Explicit cell-edge formatting takes precedence over the table border, in the same - /// top, left, bottom, right order as `strokes`. Older documents keep their table border. - #[serde(default)] - pub border_overrides: [bool; 4], - /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. - #[serde(default, skip_serializing_if = "no_flags")] - pub stroke_defined: [bool; 4], - /// IDML edge-conflict priorities, in top, left, bottom, right order. - #[serde(default, skip_serializing_if = "no_priorities")] - pub stroke_priorities: [i32; 4], - /// Applied cell style ("" = [None]). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub style: String, - /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's - /// content box, origin at the box's top left). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graphic: Option, -} - -impl Default for Cell { - fn default() -> Self { - Cell { - row_span: 1, - col_span: 1, - text: Story::new(StoryId(0)), - fill: no_fill(), - fill_tint: 1.0, - insets: default_insets(), - vj: VerticalJustification::Top, - first_baseline: FirstBaseline::Ascent, - first_baseline_min: 0.0, - rotation: 0.0, - strokes: Default::default(), - border_overrides: [false; 4], - stroke_priorities: [0; 4], - stroke_defined: [false; 4], - style: String::new(), - graphic: None, - } - } -} - -impl Cell { - pub fn with_text(text: &str, para: ParaFormat) -> Cell { - Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() } - } - /// A cell with this cell's formatting and no text (new rows / columns, unmerged cells). - pub fn blank_like(&self) -> Cell { - let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() }; - let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default(); - let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default(); - c.text = Story::new(StoryId(0)); - c.text.paras[0] = para; - c.text.chars[0].format = fmt; - c - } - pub fn has_fill(&self) -> bool { - self.fill != designcraft_color::swatch::NONE - } -} - -/// Table → Table Options → Fills (alternating pattern). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct AltFills { - pub first: u32, - pub first_color: String, - pub first_tint: f32, - pub next: u32, - pub next_color: String, - pub next_tint: f32, - pub skip_first: u32, - pub skip_last: u32, -} - -impl Default for AltFills { - fn default() -> Self { - AltFills { - first: 1, - first_color: designcraft_color::swatch::BLACK.into(), - first_tint: 0.2, - next: 1, - next_color: designcraft_color::swatch::NONE.into(), - next_tint: 1.0, - skip_first: 0, - skip_last: 0, - } - } -} - -impl AltFills { - /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. - pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { - let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); - if i < sf || i >= n.saturating_sub(sl) { - return None; - } - let period = (u64::from(self.first) + u64::from(self.next)).max(1); - let k = (i - sf) as u64 % period; - let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; - (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct TableOptions { - /// Logical column zero is drawn at the right edge for RTL tables. - pub direction: crate::TextDirection, - /// Outer border (overrides the outer cell edges). - pub border: CellStroke, - /// Per-side borders; absent sides use the legacy uniform `border`. - #[serde(skip_serializing_if = "no_borders")] - pub borders: [Option; 4], - pub space_before: f64, - pub space_after: f64, - /// Header rows repeat at the top of every text column / frame the table continues into. - pub repeat_header: bool, - pub repeat_footer: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub alt_rows: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub alt_cols: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub row_strokes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub column_strokes: Option, -} - -fn no_borders(borders: &[Option; 4]) -> bool { - borders.iter().all(Option::is_none) -} - -impl TableOptions { - pub fn border_for(&self, side: usize) -> &CellStroke { - self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) - } -} - -impl Default for TableOptions { - fn default() -> Self { - TableOptions { - direction: crate::TextDirection::LeftToRight, - border: CellStroke::default(), - borders: Default::default(), - space_before: 4.0, - space_after: -4.0, - repeat_header: true, - repeat_footer: true, - alt_rows: None, - alt_cols: None, - row_strokes: None, - column_strokes: None, - } - } -} +pub use cell::{AltFills, AltStrokes, Cell, CellStroke, Column, Row, RowHeightMode, RowKind, TableOptions}; +pub use grid::CellRange; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -371,906 +40,6 @@ pub struct Table { pub style: String, } -/// A rectangular range of cells (inclusive). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CellRange { - pub r0: usize, - pub c0: usize, - pub r1: usize, - pub c1: usize, -} - -impl CellRange { - pub fn cell(r: usize, c: usize) -> Self { - CellRange { r0: r, c0: c, r1: r, c1: c } - } - pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self { - CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) } - } - pub fn contains(&self, r: usize, c: usize) -> bool { - r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1 - } -} - -impl Table { - /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. - pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { - let cols = cols.max(1); - let mut rows = Vec::new(); - rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); - rows.extend((0..body_rows.max(1)).map(|_| Row::default())); - rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); - let w = (width / cols as f64).max(3.0); - let n = rows.len() * cols; - Table { - id, - rows, - columns: vec![Column { width: w }; cols], - cells: vec![Cell::default(); n], - options: TableOptions::default(), - style: String::new(), - } - } - - /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. - pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { - let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); - let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); - for (r, row) in data.iter().enumerate() { - for (c, s) in row.iter().enumerate() { - let Some(cell) = t.cell_mut(r, c) else { continue }; - cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); - cell.text.chars[0].format = fmt.clone(); - cell.text.insert(0, s); - } - } - t - } - - pub fn nrows(&self) -> usize { - self.rows.len() - } - pub fn ncols(&self) -> usize { - self.columns.len() - } - pub fn width(&self) -> f64 { - self.columns.iter().map(|c| c.width).sum() - } - fn idx(&self, r: usize, c: usize) -> Option { - (r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c) - } - pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { - self.cells.get(self.idx(r, c)?) - } - pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { - let i = self.idx(r, c)?; - self.cells.get_mut(i) - } - pub fn header_rows(&self) -> usize { - self.rows.iter().take_while(|r| r.kind == RowKind::Header).count() - } - pub fn footer_rows(&self) -> usize { - self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count() - } - pub fn body_rows(&self) -> std::ops::Range { - self.header_rows()..self.nrows() - self.footer_rows() - } - - /// For every grid position, the (row, col) of the cell that owns it. - pub fn owners(&self) -> Vec<(usize, usize)> { - let (nr, nc) = (self.nrows(), self.ncols()); - let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect(); - for r in 0..nr { - for c in 0..nc { - let cell = &self.cells[r * nc + c]; - if v[r * nc + c] != (r, c) { - continue; - } - for rr in r..(r + cell.row_span.max(1) as usize).min(nr) { - for cc in c..(c + cell.col_span.max(1) as usize).min(nc) { - v[rr * nc + cc] = (r, c); - } - } - } - } - v - } - - /// Owning cells touched by the range, once each in row-major order. Bounds are clipped - /// to the grid; resolving covered positions does not expand the selection rectangle. - pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { - let (nr, nc) = (self.nrows(), self.ncols()); - if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { - return Vec::new(); - } - let owners = self.owners(); - let mut selected = std::collections::BTreeSet::new(); - for r in range.r0..=range.r1.min(nr - 1) { - for c in range.c0..=range.c1.min(nc - 1) { - if let Some(&owner) = owners.get(r * nc + c) { - selected.insert(owner); - } - } - } - selected.into_iter().collect() - } - - /// The owning (top-left) cell address of grid position (r, c). - pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { - self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) - } - - /// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction). - pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> { - let owners = self.owners(); - let mut v = Vec::new(); - for r in 0..self.nrows() { - for c in 0..self.ncols() { - let cell = &self.cells[r * self.ncols() + c]; - if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) { - v.push((r, c, cell.row_span as usize, cell.col_span as usize)); - } - } - } - v - } - - fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) { - for c in &mut self.cells { - c.row_span = 1; - c.col_span = 1; - } - for &(r, c, rs, cs) in regions { - if rs == 0 || cs == 0 { - continue; - } - if let Some(cell) = self.cell_mut(r, c) { - cell.row_span = rs as u32; - cell.col_span = cs as u32; - } - } - } - - /// Insert `n` rows before row `at` (formatted like the neighbouring row). - pub fn insert_rows(&mut self, at: usize, n: usize) { - let at = at.min(self.nrows()); - if n == 0 { - return; - } - let nc = self.ncols(); - let src = if at < self.nrows() { at } else { at.saturating_sub(1) }; - let mut row = self.rows.get(src).cloned().unwrap_or_default(); - // A row inserted between body rows (or after the last header / before the first footer) is a body row. - if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind { - row.kind = RowKind::Body; - } - if at == self.nrows() && row.kind == RowKind::Header { - row.kind = RowKind::Body; - } - if at == 0 && row.kind == RowKind::Footer { - row.kind = RowKind::Body; - } - let template: Vec = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect(); - let regions: Vec<_> = self - .regions() - .into_iter() - .map(|(r, c, rs, cs)| { - if r >= at { - (r + n, c, rs, cs) - } else if r + rs > at { - (r, c, rs + n, cs) - } else { - (r, c, rs, cs) - } - }) - .collect(); - for k in 0..n { - self.rows.insert(at + k, row.clone()); - let i = (at + k) * nc; - for (c, cell) in template.iter().enumerate() { - self.cells.insert(i + c, cell.clone()); - } - } - self.set_regions(®ions); - } - - /// Delete rows `at..at+n` (at least one row always remains). - pub fn delete_rows(&mut self, at: usize, n: usize) { - let nr = self.nrows(); - let at = at.min(nr); - let n = n.min(nr - at).min(nr.saturating_sub(1)); - if n == 0 { - return; - } - let nc = self.ncols(); - let end = at + n; - let mut regions = Vec::new(); - for (r, c, rs, cs) in self.regions() { - let r1 = r + rs; - if r >= at && r1 <= end { - continue; // removed entirely - } - let new_rs = rs - (r1.min(end).saturating_sub(r.max(at))); - if r >= at && r < end { - // The owner row goes away: move its content to the first surviving row. - let content = self.cells[r * nc + c].clone(); - self.cells[end * nc + c] = content; - regions.push((at, c, new_rs, cs)); - } else if r < at { - regions.push((r, c, new_rs, cs)); - } else { - regions.push((r - n, c, rs, cs)); - } - } - self.rows.drain(at..end); - self.cells.drain(at * nc..end * nc); - self.set_regions(®ions); - } - - /// Insert `n` columns of `width` before column `at`. - pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) { - let at = at.min(self.ncols()); - if n == 0 { - return; - } - let nc = self.ncols(); - let src = if at < nc { at } else { at.saturating_sub(1) }; - let regions: Vec<_> = self - .regions() - .into_iter() - .map(|(r, c, rs, cs)| { - if c >= at { - (r, c + n, rs, cs) - } else if c + cs > at { - (r, c, rs, cs + n) - } else { - (r, c, rs, cs) - } - }) - .collect(); - let mut cells = Vec::with_capacity(self.nrows() * (nc + n)); - for r in 0..self.nrows() { - for c in 0..nc { - if c == at { - let t = self.cells[r * nc + src].blank_like(); - cells.extend(std::iter::repeat_n(t, n)); - } - cells.push(self.cells[r * nc + c].clone()); - } - if at == nc { - let t = self.cells[r * nc + src].blank_like(); - cells.extend(std::iter::repeat_n(t, n)); - } - } - self.cells = cells; - for k in 0..n { - self.columns.insert(at + k, Column { width }); - } - self.set_regions(®ions); - } - - /// Delete columns `at..at+n` (at least one column always remains). - pub fn delete_cols(&mut self, at: usize, n: usize) { - let nc = self.ncols(); - let at = at.min(nc); - let n = n.min(nc - at).min(nc.saturating_sub(1)); - if n == 0 { - return; - } - let end = at + n; - let mut regions = Vec::new(); - for (r, c, rs, cs) in self.regions() { - let c1 = c + cs; - if c >= at && c1 <= end { - continue; - } - let new_cs = cs - (c1.min(end).saturating_sub(c.max(at))); - if c >= at && c < end { - let content = self.cells[r * nc + c].clone(); - self.cells[r * nc + end] = content; - regions.push((r, at, rs, new_cs)); - } else if c < at { - regions.push((r, c, rs, new_cs)); - } else { - regions.push((r, c - n, rs, cs)); - } - } - let mut cells = Vec::with_capacity(self.nrows() * (nc - n)); - for r in 0..self.nrows() { - for c in 0..nc { - if c < at || c >= end { - cells.push(self.cells[r * nc + c].clone()); - } - } - } - self.cells = cells; - self.columns.drain(at..end); - self.set_regions(®ions); - } - - /// The smallest range containing `r` that doesn't cut through a merged region. - pub fn expand_range(&self, mut r: CellRange) -> CellRange { - loop { - let mut grown = r; - for (rr, cc, rs, cs) in self.regions() { - let (re, ce) = (rr + rs - 1, cc + cs - 1); - let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0; - if overlaps { - grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) }; - } - } - if grown == r { - return r; - } - r = grown; - } - } - - /// Merge a range into its top-left cell; the other cells' text is appended as paragraphs. - pub fn merge(&mut self, range: CellRange) -> Result<(), String> { - if range.r1 >= self.nrows() || range.c1 >= self.ncols() { - return Err("cell range out of bounds".into()); - } - let range = self.expand_range(range); - if range.r0 == range.r1 && range.c0 == range.c1 { - return Ok(()); - } - let owners = self.owners(); - let nc = self.ncols(); - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect(); - let mut extra: Vec = Vec::new(); - for r in range.r0..=range.r1 { - for c in range.c0..=range.c1 { - if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) { - let t = std::mem::take(&mut self.cells[r * nc + c]); - self.cells[r * nc + c] = t.blank_like(); - if !t.text.is_empty() { - extra.push(t.text); - } - } - } - } - let owner = &mut self.cells[range.r0 * nc + range.c0]; - for s in extra { - append_story(&mut owner.text, &s); - } - regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1)); - self.set_regions(®ions); - Ok(()) - } - - /// Unmerge the merged cell covering (r, c). - pub fn unmerge(&mut self, r: usize, c: usize) { - let (or, oc) = self.owner(r, c); - let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); - self.set_regions(®ions); - } - - /// Split each original owner touched by `range` once. Remember merged-cell cuts in the - /// original grid: a neighboring split may refine that grid before we reach the owner. - pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { - if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { - return Err("cell range out of bounds".into()); - } - let owners = self.owners(); - let mut selected = std::collections::BTreeSet::new(); - for r in range.r0..=range.r1 { - for c in range.c0..=range.c1 { - let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; - selected.insert(*owner); - } - } - let mut targets = Vec::new(); - for (r, c) in selected { - let cell = self.cell(r, c).ok_or("missing split cell")?; - let span = if horizontal { cell.row_span } else { cell.col_span } as usize; - let start = if horizontal { r } else { c }; - let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; - targets.push((r, c, cut)); - } - for i in 0..targets.len() { - let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; - let cell = self.cell(r, c).ok_or("missing split cell")?; - let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); - let mut blank = cell.blank_like(); - // Splitting creates an empty half, not a duplicate of graphic-cell content. - blank.graphic = None; - let before = if horizontal { self.nrows() } else { self.ncols() }; - if let Some(cut) = cut { - let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; - let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); - if horizontal { - regions.push((r, c, first, cs)); - regions.push((cut, c, rs - first, cs)); - } else { - regions.push((r, c, rs, first)); - regions.push((r, cut, rs, cs - first)); - } - self.set_regions(®ions); - } else { - // An originally single-track cell may now span two tracks inserted for its - // neighbor. split_cell divides that span instead of inserting another track. - self.split_cell(r, c, horizontal); - } - let first = self.cell(r, c).ok_or("missing split half")?; - let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; - let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; - blank.row_span = second.row_span; - blank.col_span = second.col_span; - *second = blank; - let after = if horizontal { self.nrows() } else { self.ncols() }; - if after > before { - let at = if horizontal { r + 1 } else { c + 1 }; - let added = after - before; - for (row, col, cut) in targets.iter_mut().skip(i + 1) { - let axis = if horizontal { row } else { col }; - if *axis >= at { - *axis += added; - } - if let Some(cut) = cut - && *cut >= at - { - *cut += added; - } - } - } - } - Ok(()) - } - - /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell - /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a - /// new row (column) that the cells beside it span, the row height (column width) shared. - pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { - if r >= self.nrows() || c >= self.ncols() { - return; - } - let (or, oc) = self.owner(r, c); - let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); - let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); - let span = if horizontal { rs } else { cs }; - if span > 1 { - let a = span.div_ceil(2); - if horizontal { - regions.push((or, oc, a, cs)); - regions.push((or + a, oc, rs - a, cs)); - } else { - regions.push((or, oc, rs, a)); - regions.push((or, oc + a, rs, cs - a)); - } - self.set_regions(®ions); - return; - } - if horizontal { - let row = self.rows[or].clone(); - self.insert_rows(or + 1, 1); - self.rows[or].height = row.height / 2.0; - // Both halves keep the split row's height mode and header/body/footer role. - self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; - // insert_rows doesn't grow regions that end at the split row; everything else on it - // spans the new row now. - let mut regions: Vec<_> = self.regions(); - let (nr, nc) = (self.nrows(), self.ncols()); - let mut covered = vec![false; nr * nc]; - for &(rr, cc, rrs, ccs) in ®ions { - for y in rr..rr + rrs { - for x in cc..cc + ccs { - covered[y * nc + x] = true; - } - } - } - for reg in regions.iter_mut() { - let (rr, cc, rrs, ccs) = *reg; - if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { - *reg = (rr, cc, rrs + 1, ccs); - } - } - for x in 0..nc { - if (oc..oc + cs).contains(&x) || covered[or * nc + x] { - continue; - } - regions.push((or, x, 2, 1)); - } - if cs > 1 { - regions.push((or + 1, oc, 1, cs)); - } - self.set_regions(®ions); - } else { - let w = self.columns[oc].width; - self.insert_cols(oc + 1, 1, w / 2.0); - self.columns[oc].width = w / 2.0; - let mut regions: Vec<_> = self.regions(); - let (nr, nc) = (self.nrows(), self.ncols()); - let mut covered = vec![false; nr * nc]; - for &(rr, cc, rrs, ccs) in ®ions { - for y in rr..rr + rrs { - for x in cc..cc + ccs { - covered[y * nc + x] = true; - } - } - } - for reg in regions.iter_mut() { - let (rr, cc, rrs, ccs) = *reg; - if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { - *reg = (rr, cc, rrs, ccs + 1); - } - } - for y in 0..nr { - if (or..or + rs).contains(&y) || covered[y * nc + oc] { - continue; - } - regions.push((y, oc, 1, 2)); - } - if rs > 1 { - regions.push((or, oc + 1, rs, 1)); - } - self.set_regions(®ions); - } - } - - /// Move row `from` to position `to` (indices before the move). Refused when merged cells span - /// rows (the move would cut them) or when the move would take a row across a header/footer - /// boundary (`from` and `to` must be the same kind of row). - pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { - let (nr, nc) = (self.nrows(), self.ncols()); - if from >= nr || to >= nr { - return Err("no such row".into()); - } - if self.regions().iter().any(|r| r.2 > 1) { - return Err("unmerge cells that span rows first".into()); - } - if self.rows[from].kind != self.rows[to].kind { - return Err("header and footer rows must stay first and last".into()); - } - let row = self.rows.remove(from); - self.rows.insert(to, row); - let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); - let at = to * nc; - for (k, c) in cells.into_iter().enumerate() { - self.cells.insert(at + k, c); - } - Ok(()) - } - - /// Move column `from` to position `to`. Refused when merged cells span columns. - pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> { - let (nr, nc) = (self.nrows(), self.ncols()); - if from >= nc || to >= nc { - return Err("no such column".into()); - } - if self.regions().iter().any(|r| r.3 > 1) { - return Err("unmerge cells that span columns first".into()); - } - let col = self.columns.remove(from); - self.columns.insert(to, col); - for r in 0..nr { - let c = self.cells.remove(r * nc + from); - self.cells.insert(r * nc + to, c); - } - Ok(()) - } - - /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. - pub fn set_header_footer(&mut self, header: usize, footer: usize) { - let n = self.nrows(); - let header = header.min(n.saturating_sub(1)); - let footer = footer.min(n - header - 1); - for (i, r) in self.rows.iter_mut().enumerate() { - r.kind = if i < header { - RowKind::Header - } else if i >= n - footer { - RowKind::Footer - } else { - RowKind::Body - }; - } - } - - /// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind` - /// rows. Header rows grow from the top and footer rows from the bottom, so the rows must - /// touch the table's edge or the rows already converted; back to body, they must touch the - /// body. One body row stays, and no merged cell may straddle a moved boundary. - pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> { - let (r0, r1) = (r0.min(r1), r0.max(r1)); - let n = self.nrows(); - if r1 >= n { - return Err("row out of range".into()); - } - let (h, f) = (self.header_rows(), self.footer_rows()); - let body_end = n - f; - let (nh, nf) = match kind { - RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()), - RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()), - RowKind::Header => (h.max(r1 + 1), f), - RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()), - RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()), - RowKind::Footer => (h, f.max(n - r0)), - RowKind::Body => { - if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) { - return Err("select the header or footer rows next to the body".into()); - } - (if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f }) - } - }; - if (nh, nf) == (h, f) { - return Err("the rows already are of that kind".into()); - } - if nh + nf >= n { - return Err("a table keeps at least one body row".into()); - } - let owners = self.owners(); - let nc = self.ncols(); - let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b)); - if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) { - return Err("unmerge the cells that span header, body and footer rows first".into()); - } - Ok((nh, nf)) - } - - /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). - pub fn plain_text(&self) -> String { - let mut out = String::new(); - for r in 0..self.nrows() { - if r > 0 { - out.push('\n'); - } - for c in 0..self.ncols() { - if c > 0 { - out.push('\t'); - } - if let Some(cell) = self.cell(r, c) { - out.push_str(&cell.text.text.replace('\n', " ")); - } - } - } - out - } - - pub fn check(&self) -> Result<(), String> { - if self.rows.is_empty() || self.columns.is_empty() { - return Err(format!("table {}: no rows or columns", self.id)); - } - if self.cells.len() != self.nrows() * self.ncols() { - return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols())); - } - // Header rows first, footer rows last. - let (h, f) = (self.header_rows(), self.footer_rows()); - if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { - return Err(format!("table {}: header/footer rows out of order", self.id)); - } - self.check_spans()?; - for cell in &self.cells { - cell.text.check()?; - } - Ok(()) - } - - /// Every merged cell stays inside the grid and no two merged cells overlap. - fn check_spans(&self) -> Result<(), String> { - let mut seen = vec![false; self.cells.len()]; - for r in 0..self.nrows() { - for c in 0..self.ncols() { - let cell = &self.cells[r * self.ncols() + c]; - if cell.row_span == 0 || cell.col_span == 0 { - return Err(format!("table {}: zero span at {r},{c}", self.id)); - } - if cell.row_span == 1 && cell.col_span == 1 { - continue; - } - if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() { - return Err(format!("table {}: span out of bounds at {r},{c}", self.id)); - } - for rr in r..r + cell.row_span as usize { - for cc in c..c + cell.col_span as usize { - let i = rr * self.ncols() + cc; - if seen[i] { - return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id)); - } - seen[i] = true; - } - } - } - } - Ok(()) - } -} - -/// Append `other` to `s` as new paragraphs. -fn append_story(s: &mut Story, other: &Story) { - if s.is_empty() { - *s = Story { id: s.id, ..other.clone() }; - s.rev = s.rev.wrapping_add(1); - return; - } - let base = s.len(); - s.insert(base, "\n"); - // Copy other's runs with their formats. - for (r, f) in other.runs() { - let at = s.len(); - s.insert_with(at, &other.text[r], f.clone()); - } - let first_new = s.paras.len() - other.paras.len(); - for (k, p) in other.paras.iter().enumerate() { - if let Some(dst) = s.paras.get_mut(first_new + k) { - *dst = p.clone(); - } - } -} - -// ---------- tables in stories ---------- - -impl crate::Document { - /// The story text edits go to: a cell's story when `cell` is set, else the story itself. - pub fn text_story(&self, story: StoryId, cell: Option) -> Option<&Story> { - let st = self.story(story)?; - match cell { - Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text), - Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text), - None => Some(st), - } - } - - /// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision). - pub fn text_story_mut(&mut self, story: StoryId, cell: Option) -> Option<&mut Story> { - let st = self.story_mut(story)?; - match cell { - Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text), - Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text), - None => Some(st), - } - } -} - -impl Story { - /// Visit this story and every text inside it (table cells, footnotes), mutably. - pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { - f(self); - for t in self.tables.values_mut() { - for c in &mut std::sync::Arc::make_mut(t).cells { - c.text.for_each_text_mut(f); - } - } - for n in &mut self.notes { - std::sync::Arc::make_mut(n).text.for_each_text_mut(f); - } - } - - /// The table whose anchor paragraph is `pi`. - pub fn para_table(&self, pi: usize) -> Option<&Arc> { - self.tables.get(&self.paras.get(pi)?.table?) - } - - /// Byte offset of a table's anchor character. - pub fn table_anchor(&self, id: u64) -> Option { - let pi = self.paras.iter().position(|p| p.table == Some(id))?; - let r = self.para_ranges().get(pi)?.clone(); - self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k) - } - - /// The table anchored at or right before byte `pos` (a caret next to the anchor). - pub fn table_at(&self, pos: usize) -> Option { - let pi = self.para_at(pos); - self.paras.get(pi)?.table - } - - /// Mutable table (copy-on-write); bumps the story revision. - pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> { - let t = self.tables.get_mut(&id)?; - self.rev = self.rev.wrapping_add(1); - Some(Arc::make_mut(t)) - } - - /// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset. - pub fn insert_table(&mut self, pos: usize, table: Table) -> usize { - let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); - let pi = self.para_at(pos); - let pstart = self.para_ranges()[pi].start; - let mut s = String::new(); - if pos > pstart { - s.push('\n'); - } - let anchor = pos + s.len(); - s.push(TABLE_ANCHOR); - if !self.text[pos..].starts_with('\n') { - s.push('\n'); - } - let id = table.id; - self.tables.insert(id, Arc::new(table)); - // Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it. - self.insert_raw(pos, &s); - let api = self.para_at(anchor); - self.paras[api].table = Some(id); - self.fix_tables(); - self.rev = self.rev.wrapping_add(1); - anchor - } - - /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it - /// still contains an anchor character, every table is referenced once, and tables nobody - /// references are dropped. An anchor that shares a table's paragraph with another anchor gets - /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. - pub(crate) fn fix_tables(&mut self) { - self.fix_tables_with(&[]); - } - - /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are - /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph - /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other - /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before - /// the fix-up, where it inserted paragraph returns. - pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { - if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { - return Vec::new(); - } - let mut owner = std::collections::BTreeMap::new(); - let mut used = std::collections::HashSet::new(); - for &(at, id) in claims { - if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) - && self.tables.contains_key(&id) - && !owner.contains_key(&at) - && used.insert(id) - { - owner.insert(at, id); - } - } - let ranges = self.para_ranges(); - for (p, r) in self.paras.iter().zip(&ranges) { - let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; - let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); - if let Some(at) = free { - used.insert(id); - owner.insert(at, id); - } - } - // A paragraph holding a table's anchor and other anchors is split before each later anchor. - let mut splits = Vec::new(); - for r in &ranges { - let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); - if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { - splits.extend(anchors.into_iter().skip(1)); - } - } - for &at in splits.iter().rev() { - self.insert_raw(at, "\n"); - } - // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. - let owner: std::collections::BTreeMap = - owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); - let ranges = self.para_ranges(); - for (p, r) in self.paras.iter_mut().zip(ranges) { - p.table = owner.range(r).next().map(|(_, &id)| id); - } - self.tables.retain(|id, _| used.contains(id)); - splits - } - - /// Check table invariants (part of [`Story::check`]). - pub(crate) fn check_tables(&self) -> Result<(), String> { - let ranges = self.para_ranges(); - let mut used = std::collections::HashSet::new(); - for (pi, p) in self.paras.iter().enumerate() { - let Some(id) = p.table else { continue }; - let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?; - if t.id != id { - return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id)); - } - if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 { - return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0)); - } - if !used.insert(id) { - return Err(format!("story {}: table {id} anchored twice", self.id.0)); - } - t.check()?; - } - if used.len() != self.tables.len() { - return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len())); - } - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; @@ -1510,115 +279,3 @@ mod tests { assert_eq!(back.blank_like().border_overrides, explicit.border_overrides); } } - -#[cfg(test)] -mod split_tests { - use super::*; - - #[test] - fn split_single_and_merged_cells() { - let mut t = Table::new(1, 2, 2, 0, 0, 100.0); - t.split_cell(0, 0, true); - assert_eq!((t.nrows(), t.ncols()), (3, 2)); - // The cell beside the split one spans both new rows. - assert_eq!(t.cell(0, 1).unwrap().row_span, 2); - assert_eq!(t.owner(1, 1), (0, 1)); - assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); - t.split_cell(2, 1, false); - assert_eq!(t.ncols(), 3); - assert_eq!(t.cell(0, 0).unwrap().col_span, 1); - assert_eq!(t.owner(2, 2), (2, 2)); - assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); - // A merged cell splits its own span instead of adding rows. - let mut m = Table::new(1, 4, 1, 0, 0, 100.0); - m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); - m.split_cell(0, 0, true); - assert_eq!(m.nrows(), 4); - assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); - } - - #[test] - fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { - for horizontal in [true, false] { - let mut t = Table::new(1, 4, 4, 0, 0, 280.0); - for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { - t.rows[i].mode = RowHeightMode::Exactly; - t.rows[i].height = if horizontal { size } else { 70.0 }; - t.columns[i].width = if horizontal { 70.0 } else { size }; - } - for r in 0..4 { - for c in 0..4 { - let cell = t.cell_mut(r, c).unwrap(); - cell.text.insert(0, &format!("{r},{c}")); - cell.style = format!("style-{r}-{c}"); - cell.insets = [r as f64 + c as f64 + 1.0; 4]; - } - } - let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { - (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) - } else { - (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) - }; - t.merge(merged).unwrap(); - let original = t.clone(); - t.split_cells(selected, horizontal).unwrap(); - t.check().unwrap(); - assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); - let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; - assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); - let first = t.cell(owner.0, owner.1).unwrap(); - let second = t.cell(lower.0, lower.1).unwrap(); - // The original three-track merge divides after its second original track, - // regardless of which of those tracks a neighboring selected cell subdivides. - assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); - assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); - assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); - assert!(second.text.is_empty()); - assert_eq!(second.style, first.style); - assert_eq!(second.insets, first.insets); - assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); - let owners = t.owners(); - assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); - let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); - let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); - before_text.sort(); - after_text.sort(); - assert_eq!(after_text, before_text, "no original text is copied or lost"); - } - } - - #[test] - fn split_range_resolves_covered_positions_without_expanding_selection() { - for horizontal in [true, false] { - let mut t = Table::new(1, 3, 3, 0, 0, 180.0); - t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); - let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; - t.split_cells(selected, horizontal).unwrap(); - t.check().unwrap(); - let owners = t.owners(); - assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); - assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); - assert_eq!(t.owner(0, 0), (0, 0)); - assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); - } - } - - #[test] - fn split_header_row_keeps_its_height_mode_and_role() { - let mut t = Table::new(1, 1, 2, 1, 1, 180.0); - t.rows[0].height = 60.0; - t.rows[0].mode = RowHeightMode::Exactly; - let body = t.rows[1].clone(); - let footer = t.rows[2].clone(); - t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); - t.check().unwrap(); - assert_eq!(t.header_rows(), 2); - for row in &t.rows[..2] { - assert_eq!(row.height, 30.0); - assert_eq!(row.mode, RowHeightMode::Exactly); - assert_eq!(row.kind, RowKind::Header); - } - assert_eq!(t.rows[2], body); - assert_eq!(t.rows[3], footer); - } -} diff --git a/crates/doc/src/table/cell.rs b/crates/doc/src/table/cell.rs new file mode 100644 index 000000000..ac022a7a8 --- /dev/null +++ b/crates/doc/src/table/cell.rs @@ -0,0 +1,348 @@ +use super::{Deserialize, FirstBaseline, ParaFormat, Serialize, Story, StoryId, StrokeType, VerticalJustification}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowHeightMode { + /// Grows with the content (the height is a minimum). + #[default] + AtLeast, + Exactly, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RowKind { + Header, + #[default] + Body, + Footer, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Row { + pub height: f64, + pub mode: RowHeightMode, + pub kind: RowKind, +} + +impl Default for Row { + fn default() -> Self { + Row { height: 3.0, mode: RowHeightMode::AtLeast, kind: RowKind::Body } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Column { + pub width: f64, +} + +/// One cell edge or the table border. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct CellStroke { + pub weight: f64, + pub color: String, + pub tint: f32, + pub kind: StrokeType, + /// Painted in the gaps of dashed, dotted and striped types. + #[serde(skip_serializing_if = "is_no_swatch")] + pub gap_color: String, + #[serde(skip_serializing_if = "is_full")] + pub gap_tint: f32, +} + +fn is_no_swatch(s: &str) -> bool { + s == designcraft_color::swatch::NONE +} + +fn is_full(t: &f32) -> bool { + *t == 1.0 +} + +impl Default for CellStroke { + fn default() -> Self { + CellStroke { + weight: 1.0, + color: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + kind: StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + } + } +} + +impl CellStroke { + pub fn none() -> Self { + CellStroke { weight: 0.0, ..Default::default() } + } + pub fn is_visible(&self) -> bool { + self.weight > 0.0 && self.color != designcraft_color::swatch::NONE + } +} + +/// Table-level alternating row/column strokes. Retained independently of cell formatting +/// so an explicit cell style or local edge continues to take precedence. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltStrokes { + pub first: u32, + pub next: u32, + pub first_stroke: CellStroke, + pub next_stroke: CellStroke, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltStrokes { + fn default() -> Self { + Self { + first: 0, + next: 0, + first_stroke: CellStroke::default(), + next_stroke: CellStroke { weight: 0.25, ..Default::default() }, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltStrokes { + /// A phase-independent stroke for unskipped, visually uniform patterns. Unequal + /// patterns and skip settings are preserved for round trips until their boundary + /// phase is supported; they must not be rendered with a guessed phase. + pub fn uniform_stroke(&self) -> Option<&CellStroke> { + if self.skip_first > 0 || self.skip_last > 0 { + return None; + } + match (self.first > 0, self.next > 0) { + (true, false) => Some(&self.first_stroke), + (false, true) => Some(&self.next_stroke), + (true, true) if self.first_stroke == self.next_stroke || (!self.first_stroke.is_visible() && !self.next_stroke.is_visible()) => { + Some(&self.first_stroke) + } + _ => None, + } + } +} + +fn no_flags(flags: &[bool; 4]) -> bool { + !flags.iter().any(|v| *v) +} + +fn no_priorities(priorities: &[i32; 4]) -> bool { + priorities.iter().all(|v| *v == 0) +} + +fn one() -> u32 { + 1 +} + +fn full() -> f32 { + 1.0 +} + +fn no_fill() -> String { + designcraft_color::swatch::NONE.into() +} + +fn is_ascent(f: &FirstBaseline) -> bool { + *f == FirstBaseline::Ascent +} + +fn is_zero_min(v: &f64) -> bool { + *v == 0.0 +} + +fn default_insets() -> [f64; 4] { + [4.0; 4] +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cell { + #[serde(default = "one")] + pub row_span: u32, + #[serde(default = "one")] + pub col_span: u32, + /// Cell content (a frameless story). + pub text: Story, + #[serde(default = "no_fill")] + pub fill: String, + #[serde(default = "full")] + pub fill_tint: f32, + /// Top, left, bottom, right. + #[serde(default = "default_insets")] + pub insets: [f64; 4], + #[serde(default)] + pub vj: VerticalJustification, + /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the + /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`). + #[serde(default, skip_serializing_if = "is_ascent")] + pub first_baseline: FirstBaseline, + #[serde(default, skip_serializing_if = "is_zero_min")] + pub first_baseline_min: f64, + /// Text rotation in degrees (only 0 is composed for now). + #[serde(default)] + pub rotation: f64, + /// Edge strokes: top, left, bottom, right. + #[serde(default)] + pub strokes: [CellStroke; 4], + /// Explicit cell-edge formatting takes precedence over the table border, in the same + /// top, left, bottom, right order as `strokes`. Older documents keep their table border. + #[serde(default)] + pub border_overrides: [bool; 4], + /// Whether an edge has supplied cell/style attributes, independently of perimeter priority. + #[serde(default, skip_serializing_if = "no_flags")] + pub stroke_defined: [bool; 4], + /// IDML edge-conflict priorities, in top, left, bottom, right order. + #[serde(default, skip_serializing_if = "no_priorities")] + pub stroke_priorities: [i32; 4], + /// Applied cell style ("" = [None]). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style: String, + /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's + /// content box, origin at the box's top left). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graphic: Option, +} + +impl Default for Cell { + fn default() -> Self { + Cell { + row_span: 1, + col_span: 1, + text: Story::new(StoryId(0)), + fill: no_fill(), + fill_tint: 1.0, + insets: default_insets(), + vj: VerticalJustification::Top, + first_baseline: FirstBaseline::Ascent, + first_baseline_min: 0.0, + rotation: 0.0, + strokes: Default::default(), + border_overrides: [false; 4], + stroke_priorities: [0; 4], + stroke_defined: [false; 4], + style: String::new(), + graphic: None, + } + } +} + +impl Cell { + pub fn with_text(text: &str, para: ParaFormat) -> Cell { + Cell { text: Story::with_text(StoryId(0), text, para), ..Default::default() } + } + /// A cell with this cell's formatting and no text (new rows / columns, unmerged cells). + pub fn blank_like(&self) -> Cell { + let mut c = Cell { row_span: 1, col_span: 1, ..self.clone() }; + let para = self.text.paras.first().cloned().map(|p| ParaFormat { table: None, ..p }).unwrap_or_default(); + let fmt = self.text.chars.first().map(|r| r.format.clone()).unwrap_or_default(); + c.text = Story::new(StoryId(0)); + c.text.paras[0] = para; + c.text.chars[0].format = fmt; + c + } + pub fn has_fill(&self) -> bool { + self.fill != designcraft_color::swatch::NONE + } +} + +/// Table → Table Options → Fills (alternating pattern). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct AltFills { + pub first: u32, + pub first_color: String, + pub first_tint: f32, + pub next: u32, + pub next_color: String, + pub next_tint: f32, + pub skip_first: u32, + pub skip_last: u32, +} + +impl Default for AltFills { + fn default() -> Self { + AltFills { + first: 1, + first_color: designcraft_color::swatch::BLACK.into(), + first_tint: 0.2, + next: 1, + next_color: designcraft_color::swatch::NONE.into(), + next_tint: 1.0, + skip_first: 0, + skip_last: 0, + } + } +} + +impl AltFills { + /// Fill for the `i`-th of `n` body rows/columns, if the pattern paints it. + pub fn fill_for(&self, i: usize, n: usize) -> Option<(&str, f32)> { + let (sf, sl) = (self.skip_first as usize, self.skip_last as usize); + if i < sf || i >= n.saturating_sub(sl) { + return None; + } + let period = (u64::from(self.first) + u64::from(self.next)).max(1); + let k = (i - sf) as u64 % period; + let (c, t) = if k < u64::from(self.first) { (&self.first_color, self.first_tint) } else { (&self.next_color, self.next_tint) }; + (c != designcraft_color::swatch::NONE).then_some((c.as_str(), t)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TableOptions { + /// Logical column zero is drawn at the right edge for RTL tables. + pub direction: crate::TextDirection, + /// Outer border (overrides the outer cell edges). + pub border: CellStroke, + /// Per-side borders; absent sides use the legacy uniform `border`. + #[serde(skip_serializing_if = "no_borders")] + pub borders: [Option; 4], + pub space_before: f64, + pub space_after: f64, + /// Header rows repeat at the top of every text column / frame the table continues into. + pub repeat_header: bool, + pub repeat_footer: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_cols: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub row_strokes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub column_strokes: Option, +} + +fn no_borders(borders: &[Option; 4]) -> bool { + borders.iter().all(Option::is_none) +} + +impl TableOptions { + pub fn border_for(&self, side: usize) -> &CellStroke { + self.borders.get(side).and_then(Option::as_ref).unwrap_or(&self.border) + } +} + +impl Default for TableOptions { + fn default() -> Self { + TableOptions { + direction: crate::TextDirection::LeftToRight, + border: CellStroke::default(), + borders: Default::default(), + space_before: 4.0, + space_after: -4.0, + repeat_header: true, + repeat_footer: true, + alt_rows: None, + alt_cols: None, + row_strokes: None, + column_strokes: None, + } + } +} diff --git a/crates/doc/src/table/edit.rs b/crates/doc/src/table/edit.rs new file mode 100644 index 000000000..8b140fde1 --- /dev/null +++ b/crates/doc/src/table/edit.rs @@ -0,0 +1,260 @@ +use super::Table; +use super::cell::{Cell, Column, RowKind}; + +impl Table { + /// Insert `n` rows before row `at` (formatted like the neighbouring row). + pub fn insert_rows(&mut self, at: usize, n: usize) { + let at = at.min(self.nrows()); + if n == 0 { + return; + } + let nc = self.ncols(); + let src = if at < self.nrows() { at } else { at.saturating_sub(1) }; + let mut row = self.rows.get(src).cloned().unwrap_or_default(); + // A row inserted between body rows (or after the last header / before the first footer) is a body row. + if at > 0 && at < self.nrows() && self.rows[at - 1].kind != self.rows[at].kind { + row.kind = RowKind::Body; + } + if at == self.nrows() && row.kind == RowKind::Header { + row.kind = RowKind::Body; + } + if at == 0 && row.kind == RowKind::Footer { + row.kind = RowKind::Body; + } + let template: Vec = (0..nc).map(|c| self.cell(src, c).map(Cell::blank_like).unwrap_or_default()).collect(); + let regions: Vec<_> = self + .regions() + .into_iter() + .map(|(r, c, rs, cs)| { + if r >= at { + (r + n, c, rs, cs) + } else if r + rs > at { + (r, c, rs + n, cs) + } else { + (r, c, rs, cs) + } + }) + .collect(); + for k in 0..n { + self.rows.insert(at + k, row.clone()); + let i = (at + k) * nc; + for (c, cell) in template.iter().enumerate() { + self.cells.insert(i + c, cell.clone()); + } + } + self.set_regions(®ions); + } + + /// Delete rows `at..at+n` (at least one row always remains). + pub fn delete_rows(&mut self, at: usize, n: usize) { + let nr = self.nrows(); + let at = at.min(nr); + let n = n.min(nr - at).min(nr.saturating_sub(1)); + if n == 0 { + return; + } + let nc = self.ncols(); + let end = at + n; + let mut regions = Vec::new(); + for (r, c, rs, cs) in self.regions() { + let r1 = r + rs; + if r >= at && r1 <= end { + continue; // removed entirely + } + let new_rs = rs - (r1.min(end).saturating_sub(r.max(at))); + if r >= at && r < end { + // The owner row goes away: move its content to the first surviving row. + let content = self.cells[r * nc + c].clone(); + self.cells[end * nc + c] = content; + regions.push((at, c, new_rs, cs)); + } else if r < at { + regions.push((r, c, new_rs, cs)); + } else { + regions.push((r - n, c, rs, cs)); + } + } + self.rows.drain(at..end); + self.cells.drain(at * nc..end * nc); + self.set_regions(®ions); + } + + /// Insert `n` columns of `width` before column `at`. + pub fn insert_cols(&mut self, at: usize, n: usize, width: f64) { + let at = at.min(self.ncols()); + if n == 0 { + return; + } + let nc = self.ncols(); + let src = if at < nc { at } else { at.saturating_sub(1) }; + let regions: Vec<_> = self + .regions() + .into_iter() + .map(|(r, c, rs, cs)| { + if c >= at { + (r, c + n, rs, cs) + } else if c + cs > at { + (r, c, rs, cs + n) + } else { + (r, c, rs, cs) + } + }) + .collect(); + let mut cells = Vec::with_capacity(self.nrows() * (nc + n)); + for r in 0..self.nrows() { + for c in 0..nc { + if c == at { + let t = self.cells[r * nc + src].blank_like(); + cells.extend(std::iter::repeat_n(t, n)); + } + cells.push(self.cells[r * nc + c].clone()); + } + if at == nc { + let t = self.cells[r * nc + src].blank_like(); + cells.extend(std::iter::repeat_n(t, n)); + } + } + self.cells = cells; + for k in 0..n { + self.columns.insert(at + k, Column { width }); + } + self.set_regions(®ions); + } + + /// Delete columns `at..at+n` (at least one column always remains). + pub fn delete_cols(&mut self, at: usize, n: usize) { + let nc = self.ncols(); + let at = at.min(nc); + let n = n.min(nc - at).min(nc.saturating_sub(1)); + if n == 0 { + return; + } + let end = at + n; + let mut regions = Vec::new(); + for (r, c, rs, cs) in self.regions() { + let c1 = c + cs; + if c >= at && c1 <= end { + continue; + } + let new_cs = cs - (c1.min(end).saturating_sub(c.max(at))); + if c >= at && c < end { + let content = self.cells[r * nc + c].clone(); + self.cells[r * nc + end] = content; + regions.push((r, at, rs, new_cs)); + } else if c < at { + regions.push((r, c, rs, new_cs)); + } else { + regions.push((r, c - n, rs, cs)); + } + } + let mut cells = Vec::with_capacity(self.nrows() * (nc - n)); + for r in 0..self.nrows() { + for c in 0..nc { + if c < at || c >= end { + cells.push(self.cells[r * nc + c].clone()); + } + } + } + self.cells = cells; + self.columns.drain(at..end); + self.set_regions(®ions); + } + + /// Move row `from` to position `to` (indices before the move). Refused when merged cells span + /// rows (the move would cut them) or when the move would take a row across a header/footer + /// boundary (`from` and `to` must be the same kind of row). + pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> { + let (nr, nc) = (self.nrows(), self.ncols()); + if from >= nr || to >= nr { + return Err("no such row".into()); + } + if self.regions().iter().any(|r| r.2 > 1) { + return Err("unmerge cells that span rows first".into()); + } + if self.rows[from].kind != self.rows[to].kind { + return Err("header and footer rows must stay first and last".into()); + } + let row = self.rows.remove(from); + self.rows.insert(to, row); + let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect(); + let at = to * nc; + for (k, c) in cells.into_iter().enumerate() { + self.cells.insert(at + k, c); + } + Ok(()) + } + + /// Move column `from` to position `to`. Refused when merged cells span columns. + pub fn move_col(&mut self, from: usize, to: usize) -> Result<(), String> { + let (nr, nc) = (self.nrows(), self.ncols()); + if from >= nc || to >= nc { + return Err("no such column".into()); + } + if self.regions().iter().any(|r| r.3 > 1) { + return Err("unmerge cells that span columns first".into()); + } + let col = self.columns.remove(from); + self.columns.insert(to, col); + for r in 0..nr { + let c = self.cells.remove(r * nc + from); + self.cells.insert(r * nc + to, c); + } + Ok(()) + } + + /// Mark the first `header` rows as header rows and the last `footer` rows as footer rows. + pub fn set_header_footer(&mut self, header: usize, footer: usize) { + let n = self.nrows(); + let header = header.min(n.saturating_sub(1)); + let footer = footer.min(n - header - 1); + for (i, r) in self.rows.iter_mut().enumerate() { + r.kind = if i < header { + RowKind::Header + } else if i >= n - footer { + RowKind::Footer + } else { + RowKind::Body + }; + } + } + + /// Header and footer row counts after Table ▸ Convert Rows turns rows `r0..=r1` into `kind` + /// rows. Header rows grow from the top and footer rows from the bottom, so the rows must + /// touch the table's edge or the rows already converted; back to body, they must touch the + /// body. One body row stays, and no merged cell may straddle a moved boundary. + pub fn convert_rows_counts(&self, r0: usize, r1: usize, kind: RowKind) -> Result<(usize, usize), String> { + let (r0, r1) = (r0.min(r1), r0.max(r1)); + let n = self.nrows(); + if r1 >= n { + return Err("row out of range".into()); + } + let (h, f) = (self.header_rows(), self.footer_rows()); + let body_end = n - f; + let (nh, nf) = match kind { + RowKind::Header if r0 > h => return Err("select rows at the top of the table or right after its header rows".into()), + RowKind::Header if r1 >= body_end => return Err("footer rows can't become header rows".into()), + RowKind::Header => (h.max(r1 + 1), f), + RowKind::Footer if r1 + 1 < body_end => return Err("select rows at the bottom of the table or right before its footer rows".into()), + RowKind::Footer if r0 < h => return Err("header rows can't become footer rows".into()), + RowKind::Footer => (h, f.max(n - r0)), + RowKind::Body => { + if (r0 < h && r1 + 1 < h) || (r1 >= body_end && r0 > body_end) { + return Err("select the header or footer rows next to the body".into()); + } + (if r0 < h { r0 } else { h }, if r1 >= body_end { n - 1 - r1 } else { f }) + } + }; + if (nh, nf) == (h, f) { + return Err("the rows already are of that kind".into()); + } + if nh + nf >= n { + return Err("a table keeps at least one body row".into()); + } + let owners = self.owners(); + let nc = self.ncols(); + let straddled = |b: usize| (0..nc).any(|c| owners.get(b * nc + c).is_some_and(|o| o.0 < b)); + if (nh != h && straddled(nh)) || (nf != f && straddled(n - nf)) { + return Err("unmerge the cells that span header, body and footer rows first".into()); + } + Ok((nh, nf)) + } +} diff --git a/crates/doc/src/table/grid.rs b/crates/doc/src/table/grid.rs new file mode 100644 index 000000000..a64f2cd99 --- /dev/null +++ b/crates/doc/src/table/grid.rs @@ -0,0 +1,239 @@ +use super::cell::{Cell, Column, Row, RowKind, TableOptions}; +use super::{CharFormat, Deserialize, ParaFormat, Serialize, Story, StoryId, Table}; + +/// A rectangular range of cells (inclusive). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CellRange { + pub r0: usize, + pub c0: usize, + pub r1: usize, + pub c1: usize, +} + +impl CellRange { + pub fn cell(r: usize, c: usize) -> Self { + CellRange { r0: r, c0: c, r1: r, c1: c } + } + pub fn new(ra: usize, ca: usize, rb: usize, cb: usize) -> Self { + CellRange { r0: ra.min(rb), c0: ca.min(cb), r1: ra.max(rb), c1: ca.max(cb) } + } + pub fn contains(&self, r: usize, c: usize) -> bool { + r >= self.r0 && r <= self.r1 && c >= self.c0 && c <= self.c1 + } +} + +impl Table { + /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`. + pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table { + let cols = cols.max(1); + let mut rows = Vec::new(); + rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() })); + rows.extend((0..body_rows.max(1)).map(|_| Row::default())); + rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); + let w = (width / cols as f64).max(3.0); + let n = rows.len() * cols; + Table { + id, + rows, + columns: vec![Column { width: w }; cols], + cells: vec![Cell::default(); n], + options: TableOptions::default(), + style: String::new(), + } + } + + /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. + pub fn from_strings(id: u64, data: &[Vec], width: f64, para: &ParaFormat, fmt: &CharFormat) -> Table { + let cols = data.iter().map(Vec::len).max().unwrap_or(1).max(1); + let mut t = Table::new(id, data.len().max(1), cols, 0, 0, width); + for (r, row) in data.iter().enumerate() { + for (c, s) in row.iter().enumerate() { + let Some(cell) = t.cell_mut(r, c) else { continue }; + cell.text = Story::with_text(StoryId(0), "", ParaFormat { table: None, ..para.clone() }); + cell.text.chars[0].format = fmt.clone(); + cell.text.insert(0, s); + } + } + t + } + + pub fn nrows(&self) -> usize { + self.rows.len() + } + + pub fn ncols(&self) -> usize { + self.columns.len() + } + + pub fn width(&self) -> f64 { + self.columns.iter().map(|c| c.width).sum() + } + + fn idx(&self, r: usize, c: usize) -> Option { + (r < self.nrows() && c < self.ncols()).then(|| r * self.ncols() + c) + } + + pub fn cell(&self, r: usize, c: usize) -> Option<&Cell> { + self.cells.get(self.idx(r, c)?) + } + + pub fn cell_mut(&mut self, r: usize, c: usize) -> Option<&mut Cell> { + let i = self.idx(r, c)?; + self.cells.get_mut(i) + } + + pub fn header_rows(&self) -> usize { + self.rows.iter().take_while(|r| r.kind == RowKind::Header).count() + } + + pub fn footer_rows(&self) -> usize { + self.rows.iter().rev().take_while(|r| r.kind == RowKind::Footer).count() + } + + pub fn body_rows(&self) -> std::ops::Range { + self.header_rows()..self.nrows() - self.footer_rows() + } + + /// For every grid position, the (row, col) of the cell that owns it. + pub fn owners(&self) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + let mut v: Vec<(usize, usize)> = (0..nr * nc).map(|i| (i / nc.max(1), i % nc.max(1))).collect(); + for r in 0..nr { + for c in 0..nc { + let cell = &self.cells[r * nc + c]; + if v[r * nc + c] != (r, c) { + continue; + } + for rr in r..(r + cell.row_span.max(1) as usize).min(nr) { + for cc in c..(c + cell.col_span.max(1) as usize).min(nc) { + v[rr * nc + cc] = (r, c); + } + } + } + } + v + } + + /// Owning cells touched by the range, once each in row-major order. Bounds are clipped + /// to the grid; resolving covered positions does not expand the selection rectangle. + pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> { + let (nr, nc) = (self.nrows(), self.ncols()); + if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 { + return Vec::new(); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1.min(nr - 1) { + for c in range.c0..=range.c1.min(nc - 1) { + if let Some(&owner) = owners.get(r * nc + c) { + selected.insert(owner); + } + } + } + selected.into_iter().collect() + } + + /// The owning (top-left) cell address of grid position (r, c). + pub fn owner(&self, r: usize, c: usize) -> (usize, usize) { + self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c)) + } + + /// Merged regions `(r, c, row_span, col_span)` (span > 1 in some direction). + pub fn regions(&self) -> Vec<(usize, usize, usize, usize)> { + let owners = self.owners(); + let mut v = Vec::new(); + for r in 0..self.nrows() { + for c in 0..self.ncols() { + let cell = &self.cells[r * self.ncols() + c]; + if owners[r * self.ncols() + c] == (r, c) && (cell.row_span > 1 || cell.col_span > 1) { + v.push((r, c, cell.row_span as usize, cell.col_span as usize)); + } + } + } + v + } + + pub(super) fn set_regions(&mut self, regions: &[(usize, usize, usize, usize)]) { + for c in &mut self.cells { + c.row_span = 1; + c.col_span = 1; + } + for &(r, c, rs, cs) in regions { + if rs == 0 || cs == 0 { + continue; + } + if let Some(cell) = self.cell_mut(r, c) { + cell.row_span = rs as u32; + cell.col_span = cs as u32; + } + } + } + + /// Cell texts, rows separated by `\n`, cells by `\t` (Convert Table to Text, search, agents). + pub fn plain_text(&self) -> String { + let mut out = String::new(); + for r in 0..self.nrows() { + if r > 0 { + out.push('\n'); + } + for c in 0..self.ncols() { + if c > 0 { + out.push('\t'); + } + if let Some(cell) = self.cell(r, c) { + out.push_str(&cell.text.text.replace('\n', " ")); + } + } + } + out + } + + pub fn check(&self) -> Result<(), String> { + if self.rows.is_empty() || self.columns.is_empty() { + return Err(format!("table {}: no rows or columns", self.id)); + } + if self.cells.len() != self.nrows() * self.ncols() { + return Err(format!("table {}: {} cells for {}×{}", self.id, self.cells.len(), self.nrows(), self.ncols())); + } + // Header rows first, footer rows last. + let (h, f) = (self.header_rows(), self.footer_rows()); + if self.rows[h..self.nrows() - f].iter().any(|r| r.kind != RowKind::Body) { + return Err(format!("table {}: header/footer rows out of order", self.id)); + } + self.check_spans()?; + for cell in &self.cells { + cell.text.check()?; + } + Ok(()) + } + + /// Every merged cell stays inside the grid and no two merged cells overlap. + fn check_spans(&self) -> Result<(), String> { + let mut seen = vec![false; self.cells.len()]; + for r in 0..self.nrows() { + for c in 0..self.ncols() { + let cell = &self.cells[r * self.ncols() + c]; + if cell.row_span == 0 || cell.col_span == 0 { + return Err(format!("table {}: zero span at {r},{c}", self.id)); + } + if cell.row_span == 1 && cell.col_span == 1 { + continue; + } + if r + cell.row_span as usize > self.nrows() || c + cell.col_span as usize > self.ncols() { + return Err(format!("table {}: span out of bounds at {r},{c}", self.id)); + } + for rr in r..r + cell.row_span as usize { + for cc in c..c + cell.col_span as usize { + let i = rr * self.ncols() + cc; + if seen[i] { + return Err(format!("table {}: overlapping merged cells at {rr},{cc}", self.id)); + } + seen[i] = true; + } + } + } + } + Ok(()) + } +} diff --git a/crates/doc/src/table/merge.rs b/crates/doc/src/table/merge.rs new file mode 100644 index 000000000..7f3faecfe --- /dev/null +++ b/crates/doc/src/table/merge.rs @@ -0,0 +1,341 @@ +use super::cell::Row; +#[cfg(test)] +use super::cell::{RowHeightMode, RowKind}; +use super::grid::CellRange; +use super::story::append_story; +use super::{Story, Table}; + +impl Table { + /// The smallest range containing `r` that doesn't cut through a merged region. + pub fn expand_range(&self, mut r: CellRange) -> CellRange { + loop { + let mut grown = r; + for (rr, cc, rs, cs) in self.regions() { + let (re, ce) = (rr + rs - 1, cc + cs - 1); + let overlaps = rr <= r.r1 && re >= r.r0 && cc <= r.c1 && ce >= r.c0; + if overlaps { + grown = CellRange { r0: grown.r0.min(rr), c0: grown.c0.min(cc), r1: grown.r1.max(re), c1: grown.c1.max(ce) }; + } + } + if grown == r { + return r; + } + r = grown; + } + } + + /// Merge a range into its top-left cell; the other cells' text is appended as paragraphs. + pub fn merge(&mut self, range: CellRange) -> Result<(), String> { + if range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let range = self.expand_range(range); + if range.r0 == range.r1 && range.c0 == range.c1 { + return Ok(()); + } + let owners = self.owners(); + let nc = self.ncols(); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(r, c, _, _)| !range.contains(r, c)).collect(); + let mut extra: Vec = Vec::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + if (r, c) != (range.r0, range.c0) && owners[r * nc + c] == (r, c) { + let t = std::mem::take(&mut self.cells[r * nc + c]); + self.cells[r * nc + c] = t.blank_like(); + if !t.text.is_empty() { + extra.push(t.text); + } + } + } + } + let owner = &mut self.cells[range.r0 * nc + range.c0]; + for s in extra { + append_story(&mut owner.text, &s); + } + regions.push((range.r0, range.c0, range.r1 - range.r0 + 1, range.c1 - range.c0 + 1)); + self.set_regions(®ions); + Ok(()) + } + + /// Unmerge the merged cell covering (r, c). + pub fn unmerge(&mut self, r: usize, c: usize) { + let (or, oc) = self.owner(r, c); + let regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + self.set_regions(®ions); + } + + /// Split each original owner touched by `range` once. Remember merged-cell cuts in the + /// original grid: a neighboring split may refine that grid before we reach the owner. + pub fn split_cells(&mut self, range: CellRange, horizontal: bool) -> Result<(), String> { + if range.r0 > range.r1 || range.c0 > range.c1 || range.r1 >= self.nrows() || range.c1 >= self.ncols() { + return Err("cell range out of bounds".into()); + } + let owners = self.owners(); + let mut selected = std::collections::BTreeSet::new(); + for r in range.r0..=range.r1 { + for c in range.c0..=range.c1 { + let owner = owners.get(r * self.ncols() + c).ok_or("missing cell owner")?; + selected.insert(*owner); + } + } + let mut targets = Vec::new(); + for (r, c) in selected { + let cell = self.cell(r, c).ok_or("missing split cell")?; + let span = if horizontal { cell.row_span } else { cell.col_span } as usize; + let start = if horizontal { r } else { c }; + let cut = if span > 1 { Some(start.checked_add(span.div_ceil(2)).ok_or("invalid split boundary")?) } else { None }; + targets.push((r, c, cut)); + } + for i in 0..targets.len() { + let (r, c, cut) = *targets.get(i).ok_or("missing split target")?; + let cell = self.cell(r, c).ok_or("missing split cell")?; + let (rs, cs) = (cell.row_span as usize, cell.col_span as usize); + let mut blank = cell.blank_like(); + // Splitting creates an empty half, not a duplicate of graphic-cell content. + blank.graphic = None; + let before = if horizontal { self.nrows() } else { self.ncols() }; + if let Some(cut) = cut { + let (start, span) = if horizontal { (r, rs) } else { (c, cs) }; + let first = cut.checked_sub(start).filter(|n| *n > 0 && *n < span).ok_or("invalid split boundary")?; + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (r, c)).collect(); + if horizontal { + regions.push((r, c, first, cs)); + regions.push((cut, c, rs - first, cs)); + } else { + regions.push((r, c, rs, first)); + regions.push((r, cut, rs, cs - first)); + } + self.set_regions(®ions); + } else { + // An originally single-track cell may now span two tracks inserted for its + // neighbor. split_cell divides that span instead of inserting another track. + self.split_cell(r, c, horizontal); + } + let first = self.cell(r, c).ok_or("missing split half")?; + let second = if horizontal { (r + first.row_span as usize, c) } else { (r, c + first.col_span as usize) }; + let second = self.cell_mut(second.0, second.1).ok_or("missing split half")?; + blank.row_span = second.row_span; + blank.col_span = second.col_span; + *second = blank; + let after = if horizontal { self.nrows() } else { self.ncols() }; + if after > before { + let at = if horizontal { r + 1 } else { c + 1 }; + let added = after - before; + for (row, col, cut) in targets.iter_mut().skip(i + 1) { + let axis = if horizontal { row } else { col }; + if *axis >= at { + *axis += added; + } + if let Some(cut) = cut + && *cut >= at + { + *cut += added; + } + } + } + } + Ok(()) + } + + /// Split the cell covering (r, c) in two: `horizontal` stacks the halves (Split Cell + /// Horizontally), otherwise side by side. A merged cell splits its span; a single cell gets a + /// new row (column) that the cells beside it span, the row height (column width) shared. + pub fn split_cell(&mut self, r: usize, c: usize, horizontal: bool) { + if r >= self.nrows() || c >= self.ncols() { + return; + } + let (or, oc) = self.owner(r, c); + let (rs, cs) = self.cell(or, oc).map_or((1, 1), |x| (x.row_span as usize, x.col_span as usize)); + let mut regions: Vec<_> = self.regions().into_iter().filter(|&(rr, cc, _, _)| (rr, cc) != (or, oc)).collect(); + let span = if horizontal { rs } else { cs }; + if span > 1 { + let a = span.div_ceil(2); + if horizontal { + regions.push((or, oc, a, cs)); + regions.push((or + a, oc, rs - a, cs)); + } else { + regions.push((or, oc, rs, a)); + regions.push((or, oc + a, rs, cs - a)); + } + self.set_regions(®ions); + return; + } + if horizontal { + let row = self.rows[or].clone(); + self.insert_rows(or + 1, 1); + self.rows[or].height = row.height / 2.0; + // Both halves keep the split row's height mode and header/body/footer role. + self.rows[or + 1] = Row { height: row.height / 2.0, ..row }; + // insert_rows doesn't grow regions that end at the split row; everything else on it + // spans the new row now. + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if rr + rrs == or + 1 && !(cc <= oc && oc < cc + ccs) { + *reg = (rr, cc, rrs + 1, ccs); + } + } + for x in 0..nc { + if (oc..oc + cs).contains(&x) || covered[or * nc + x] { + continue; + } + regions.push((or, x, 2, 1)); + } + if cs > 1 { + regions.push((or + 1, oc, 1, cs)); + } + self.set_regions(®ions); + } else { + let w = self.columns[oc].width; + self.insert_cols(oc + 1, 1, w / 2.0); + self.columns[oc].width = w / 2.0; + let mut regions: Vec<_> = self.regions(); + let (nr, nc) = (self.nrows(), self.ncols()); + let mut covered = vec![false; nr * nc]; + for &(rr, cc, rrs, ccs) in ®ions { + for y in rr..rr + rrs { + for x in cc..cc + ccs { + covered[y * nc + x] = true; + } + } + } + for reg in regions.iter_mut() { + let (rr, cc, rrs, ccs) = *reg; + if cc + ccs == oc + 1 && !(rr <= or && or < rr + rrs) { + *reg = (rr, cc, rrs, ccs + 1); + } + } + for y in 0..nr { + if (or..or + rs).contains(&y) || covered[y * nc + oc] { + continue; + } + regions.push((y, oc, 1, 2)); + } + if rs > 1 { + regions.push((or, oc + 1, rs, 1)); + } + self.set_regions(®ions); + } + } +} + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn split_single_and_merged_cells() { + let mut t = Table::new(1, 2, 2, 0, 0, 100.0); + t.split_cell(0, 0, true); + assert_eq!((t.nrows(), t.ncols()), (3, 2)); + // The cell beside the split one spans both new rows. + assert_eq!(t.cell(0, 1).unwrap().row_span, 2); + assert_eq!(t.owner(1, 1), (0, 1)); + assert_eq!(t.owner(1, 0), (1, 0), "the new half is its own cell"); + t.split_cell(2, 1, false); + assert_eq!(t.ncols(), 3); + assert_eq!(t.cell(0, 0).unwrap().col_span, 1); + assert_eq!(t.owner(2, 2), (2, 2)); + assert_eq!(t.owner(0, 2), (0, 1), "the merged cell above widens"); + // A merged cell splits its own span instead of adding rows. + let mut m = Table::new(1, 4, 1, 0, 0, 100.0); + m.merge(CellRange { r0: 0, c0: 0, r1: 3, c1: 0 }).unwrap(); + m.split_cell(0, 0, true); + assert_eq!(m.nrows(), 4); + assert_eq!((m.cell(0, 0).unwrap().row_span, m.cell(2, 0).unwrap().row_span), (2, 2)); + } + + #[test] + fn split_range_keeps_original_merged_cut_when_neighbor_tracks_are_refined() { + for horizontal in [true, false] { + let mut t = Table::new(1, 4, 4, 0, 0, 280.0); + for (i, size) in [40.0, 60.0, 80.0, 100.0].into_iter().enumerate() { + t.rows[i].mode = RowHeightMode::Exactly; + t.rows[i].height = if horizontal { size } else { 70.0 }; + t.columns[i].width = if horizontal { 70.0 } else { size }; + } + for r in 0..4 { + for c in 0..4 { + let cell = t.cell_mut(r, c).unwrap(); + cell.text.insert(0, &format!("{r},{c}")); + cell.style = format!("style-{r}-{c}"); + cell.insets = [r as f64 + c as f64 + 1.0; 4]; + } + } + let (merged, selected, owner, lower, neighbor, moved_neighbor) = if horizontal { + (CellRange::new(0, 1, 2, 1), CellRange::new(0, 0, 2, 1), (0, 1), (4, 1), (3, 3), (6, 3)) + } else { + (CellRange::new(1, 0, 1, 2), CellRange::new(0, 0, 1, 2), (1, 0), (1, 4), (3, 3), (3, 6)) + }; + t.merge(merged).unwrap(); + let original = t.clone(); + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (7, 4) } else { (4, 7) }); + let sizes: Vec<_> = if horizontal { t.rows.iter().map(|r| r.height).collect() } else { t.columns.iter().map(|c| c.width).collect() }; + assert_eq!(sizes, [20.0, 20.0, 30.0, 30.0, 40.0, 40.0, 100.0]); + let first = t.cell(owner.0, owner.1).unwrap(); + let second = t.cell(lower.0, lower.1).unwrap(); + // The original three-track merge divides after its second original track, + // regardless of which of those tracks a neighboring selected cell subdivides. + assert_eq!(if horizontal { first.row_span } else { first.col_span }, 4); + assert_eq!(if horizontal { second.row_span } else { second.col_span }, 2); + assert_eq!(first.text, original.cell(owner.0, owner.1).unwrap().text); + assert!(second.text.is_empty()); + assert_eq!(second.style, first.style); + assert_eq!(second.insets, first.insets); + assert_eq!(t.cell(moved_neighbor.0, moved_neighbor.1), original.cell(neighbor.0, neighbor.1)); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 18, "four original selected owners split once each"); + let mut before_text: Vec<_> = original.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + let mut after_text: Vec<_> = t.cells.iter().filter(|c| !c.text.is_empty()).map(|c| c.text.text.clone()).collect(); + before_text.sort(); + after_text.sort(); + assert_eq!(after_text, before_text, "no original text is copied or lost"); + } + } + + #[test] + fn split_range_resolves_covered_positions_without_expanding_selection() { + for horizontal in [true, false] { + let mut t = Table::new(1, 3, 3, 0, 0, 180.0); + t.merge(CellRange::new(0, 0, 1, 1)).unwrap(); + let selected = if horizontal { CellRange::new(1, 1, 1, 2) } else { CellRange::new(1, 1, 2, 1) }; + t.split_cells(selected, horizontal).unwrap(); + t.check().unwrap(); + let owners = t.owners(); + assert_eq!(owners.iter().copied().collect::>().len(), 8, "only the two touched owners split"); + assert_eq!((t.nrows(), t.ncols()), if horizontal { (4, 3) } else { (3, 4) }); + assert_eq!(t.owner(0, 0), (0, 0)); + assert_eq!(if horizontal { t.owner(1, 0) } else { t.owner(0, 1) }, if horizontal { (1, 0) } else { (0, 1) }); + } + } + + #[test] + fn split_header_row_keeps_its_height_mode_and_role() { + let mut t = Table::new(1, 1, 2, 1, 1, 180.0); + t.rows[0].height = 60.0; + t.rows[0].mode = RowHeightMode::Exactly; + let body = t.rows[1].clone(); + let footer = t.rows[2].clone(); + t.split_cells(CellRange::new(0, 0, 0, 1), true).unwrap(); + t.check().unwrap(); + assert_eq!(t.header_rows(), 2); + for row in &t.rows[..2] { + assert_eq!(row.height, 30.0); + assert_eq!(row.mode, RowHeightMode::Exactly); + assert_eq!(row.kind, RowKind::Header); + } + assert_eq!(t.rows[2], body); + assert_eq!(t.rows[3], footer); + } +} diff --git a/crates/doc/src/table/story.rs b/crates/doc/src/table/story.rs new file mode 100644 index 000000000..1e69a3738 --- /dev/null +++ b/crates/doc/src/table/story.rs @@ -0,0 +1,195 @@ +use super::{Arc, Story, StoryId, TABLE_ANCHOR, Table}; + +/// Append `other` to `s` as new paragraphs. +pub(super) fn append_story(s: &mut Story, other: &Story) { + if s.is_empty() { + *s = Story { id: s.id, ..other.clone() }; + s.rev = s.rev.wrapping_add(1); + return; + } + let base = s.len(); + s.insert(base, "\n"); + // Copy other's runs with their formats. + for (r, f) in other.runs() { + let at = s.len(); + s.insert_with(at, &other.text[r], f.clone()); + } + let first_new = s.paras.len() - other.paras.len(); + for (k, p) in other.paras.iter().enumerate() { + if let Some(dst) = s.paras.get_mut(first_new + k) { + *dst = p.clone(); + } + } +} + +// ---------- tables in stories ---------- + +impl crate::Document { + /// The story text edits go to: a cell's story when `cell` is set, else the story itself. + pub fn text_story(&self, story: StoryId, cell: Option) -> Option<&Story> { + let st = self.story(story)?; + match cell { + Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note(c.row as u64).map(|n| &n.text), + Some(c) => st.tables.get(&c.table)?.cell(c.row, c.col).map(|x| &x.text), + None => Some(st), + } + } + + /// Mutable [`Document::text_story`] (a cell edit bumps the containing story's revision). + pub fn text_story_mut(&mut self, story: StoryId, cell: Option) -> Option<&mut Story> { + let st = self.story_mut(story)?; + match cell { + Some(c) if c.table == crate::FOOTNOTE_TABLE => st.note_mut(c.row as u64).map(|n| &mut n.text), + Some(c) => st.table_mut(c.table)?.cell_mut(c.row, c.col).map(|x| &mut x.text), + None => Some(st), + } + } +} + +impl Story { + /// Visit this story and every text inside it (table cells, footnotes), mutably. + pub fn for_each_text_mut(&mut self, f: &mut impl FnMut(&mut Story)) { + f(self); + for t in self.tables.values_mut() { + for c in &mut std::sync::Arc::make_mut(t).cells { + c.text.for_each_text_mut(f); + } + } + for n in &mut self.notes { + std::sync::Arc::make_mut(n).text.for_each_text_mut(f); + } + } + + /// The table whose anchor paragraph is `pi`. + pub fn para_table(&self, pi: usize) -> Option<&Arc
> { + self.tables.get(&self.paras.get(pi)?.table?) + } + + /// Byte offset of a table's anchor character. + pub fn table_anchor(&self, id: u64) -> Option { + let pi = self.paras.iter().position(|p| p.table == Some(id))?; + let r = self.para_ranges().get(pi)?.clone(); + self.text[r.clone()].find(TABLE_ANCHOR).map(|k| r.start + k) + } + + /// The table anchored at or right before byte `pos` (a caret next to the anchor). + pub fn table_at(&self, pos: usize) -> Option { + let pi = self.para_at(pos); + self.paras.get(pi)?.table + } + + /// Mutable table (copy-on-write); bumps the story revision. + pub fn table_mut(&mut self, id: u64) -> Option<&mut Table> { + let t = self.tables.get_mut(&id)?; + self.rev = self.rev.wrapping_add(1); + Some(Arc::make_mut(t)) + } + + /// Insert `table` at `pos`, giving its anchor its own paragraph. Returns the anchor's byte offset. + pub fn insert_table(&mut self, pos: usize, table: Table) -> usize { + let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len())); + let pi = self.para_at(pos); + let pstart = self.para_ranges()[pi].start; + let mut s = String::new(); + if pos > pstart { + s.push('\n'); + } + let anchor = pos + s.len(); + s.push(TABLE_ANCHOR); + if !self.text[pos..].starts_with('\n') { + s.push('\n'); + } + let id = table.id; + self.tables.insert(id, Arc::new(table)); + // Insert the text first (fix-up would drop an unreferenced table), then point the paragraph at it. + self.insert_raw(pos, &s); + let api = self.para_at(anchor); + self.paras[api].table = Some(id); + self.fix_tables(); + self.rev = self.rev.wrapping_add(1); + anchor + } + + /// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it + /// still contains an anchor character, every table is referenced once, and tables nobody + /// references are dropped. An anchor that shares a table's paragraph with another anchor gets + /// a paragraph of its own again, so a table paragraph always holds exactly one anchor. + pub(crate) fn fix_tables(&mut self) { + self.fix_tables_with(&[]); + } + + /// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are + /// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph + /// names one table, so merging two anchor paragraphs would otherwise lose one). Every other + /// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before + /// the fix-up, where it inserted paragraph returns. + pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec { + if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) { + return Vec::new(); + } + let mut owner = std::collections::BTreeMap::new(); + let mut used = std::collections::HashSet::new(); + for &(at, id) in claims { + if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR)) + && self.tables.contains_key(&id) + && !owner.contains_key(&at) + && used.insert(id) + { + owner.insert(at, id); + } + } + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter().zip(&ranges) { + let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue }; + let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at)); + if let Some(at) = free { + used.insert(id); + owner.insert(at, id); + } + } + // A paragraph holding a table's anchor and other anchors is split before each later anchor. + let mut splits = Vec::new(); + for r in &ranges { + let anchors: Vec = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect(); + if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) { + splits.extend(anchors.into_iter().skip(1)); + } + } + for &at in splits.iter().rev() { + self.insert_raw(at, "\n"); + } + // Each owned anchor, moved past the returns inserted before it, names its paragraph's table. + let owner: std::collections::BTreeMap = + owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect(); + let ranges = self.para_ranges(); + for (p, r) in self.paras.iter_mut().zip(ranges) { + p.table = owner.range(r).next().map(|(_, &id)| id); + } + self.tables.retain(|id, _| used.contains(id)); + splits + } + + /// Check table invariants (part of [`Story::check`]). + pub(crate) fn check_tables(&self) -> Result<(), String> { + let ranges = self.para_ranges(); + let mut used = std::collections::HashSet::new(); + for (pi, p) in self.paras.iter().enumerate() { + let Some(id) = p.table else { continue }; + let t = self.tables.get(&id).ok_or_else(|| format!("story {}: paragraph {pi} points at missing table {id}", self.id.0))?; + if t.id != id { + return Err(format!("story {}: table key {id} != id {}", self.id.0, t.id)); + } + if self.text[ranges[pi].clone()].matches(TABLE_ANCHOR).count() != 1 { + return Err(format!("story {}: table {id} paragraph must hold exactly one anchor", self.id.0)); + } + if !used.insert(id) { + return Err(format!("story {}: table {id} anchored twice", self.id.0)); + } + t.check()?; + } + if used.len() != self.tables.len() { + return Err(format!("story {}: {} tables but {} anchored", self.id.0, self.tables.len(), used.len())); + } + Ok(()) + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 69e0e8d50..c1c0f1d33 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ designcraft-epub = { workspace = true } designcraft-pptx = { workspace = true } designcraft-textimport = { workspace = true } designcraft-pdf = { workspace = true } +craft-prepress = { workspace = true } designcraft-tools = { workspace = true } designcraft-idml = { workspace = true } indd = { workspace = true } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 98% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs index 1d6294ea6..1a75d7ece 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd.rs @@ -6,7 +6,7 @@ mod articles; pub mod book; mod buttons; pub(crate) mod captions; -mod changes; +pub(crate) mod changes; mod color_theme; mod colorsettings; mod conditions; @@ -15,7 +15,9 @@ mod edit; mod editorial; mod endnotes; mod export; +pub(crate) use export::print_pdf; mod file; +mod file_info; pub(crate) mod find; pub use edit::copied_objects_text; pub use find::same_find_domain; @@ -47,6 +49,7 @@ mod pdflayers; mod place_text; pub mod preflight; mod prefs; +mod prepress; mod print; mod qr; mod rulers; @@ -169,6 +172,7 @@ pub fn command_specs() -> &'static [CommandSpec] { SPECS.get_or_init(|| { let mut v = Vec::new(); v.extend(file::specs()); + v.extend(file_info::specs()); v.extend(interchange::specs()); v.extend(export::specs()); v.extend(edit::specs()); @@ -208,6 +212,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(thread::specs()); v.extend(tabs::specs()); v.extend(style::specs()); + v.extend(prepress::specs()); v.extend(load_styles::specs()); v.extend(table::specs()); v.extend(layout::specs()); diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index 72b2f085b..fec81de4b 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -78,6 +78,70 @@ pub fn specs() -> Vec { ] } +/// The first open subpath (two anchors or more) of an item. +fn first_open_subpath(d: &Document, id: ItemId) -> Option { + d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1)) +} + +/// Close every open subpath of one path. +fn close_open_subpaths(d: &mut Document, id: ItemId) -> Result { + let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let mut closed = 0; + for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) { + sp.closed = true; + closed += 1; + } + if closed == 0 { + return Err(bad("path.join", "the path is already closed")); + } + as_path(&mut it.shape); + Ok(json!({"id": id.0, "closed": closed})) +} + +/// Join the open subpaths of two paths at their nearest ends, into the first path. +fn join_open_pair(d: &mut Document, sel: &mut designcraft_doc::Selection, a: ItemId, b: ItemId) -> Result { + let (Some(sa), Some(sb)) = (first_open_subpath(d, a), first_open_subpath(d, b)) else { + return Err(bad("path.join", "select two open paths")); + }; + // b's path in a's space. + let inv_a = to_inner(d, a).ok_or(designcraft_doc::DocError::NoItem(a))?; + let xf_b = to_inner(d, b).ok_or(designcraft_doc::DocError::NoItem(b))?.inverse(); + let m = inv_a * xf_b; + let mut pb = d.item(b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(b))?; + for an in &mut pb.anchors { + *an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind }; + } + let ita = d.item_mut(a).ok_or(designcraft_doc::DocError::NoItem(a))?; + let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?; + // The pair of ends nearest each other; then a's end meets b's start. + let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p)); + let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else { + return Err(bad("path.join", "select two open paths")); + }; + let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)]; + let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else { + return Err(bad("path.join", "select two open paths")); + }; + if rev_a { + pa.reverse(); + } + if rev_b { + pb.reverse(); + } + let mut rest = pb.anchors.into_iter(); + if let Some(first) = rest.next() { + match pa.anchors.last_mut() { + Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out), + _ => pa.anchors.push(first), + } + } + pa.anchors.extend(rest); + as_path(&mut ita.shape); + d.remove_item(b)?; + *sel = designcraft_doc::Selection::items(vec![a]); + Ok(json!({"id": a.0})) +} + /// Join Path: close one open path, or join two open paths at their nearest ends. fn join_paths(s: &mut Session, p: &Value) -> Result { let ids = super::targets(s, p)?; @@ -90,63 +154,9 @@ fn join_paths(s: &mut Session, p: &Value) -> Result { path_editable(d, *id, "path.join")?; } } - let open = |d: &Document, id: ItemId| d.item(id).and_then(|it| it.path.subpaths.iter().position(|sp| !sp.closed && sp.anchors.len() > 1)); match ids.as_slice() { - [id] => { - let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; - let mut closed = 0; - for sp in it.path.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() > 1) { - sp.closed = true; - closed += 1; - } - if closed == 0 { - return Err(bad("path.join", "the path is already closed")); - } - as_path(&mut it.shape); - Ok(json!({"id": id.0, "closed": closed})) - } - [a, b] => { - let (Some(sa), Some(sb)) = (open(d, *a), open(d, *b)) else { - return Err(bad("path.join", "select two open paths")); - }; - // b's path in a's space. - let inv_a = to_inner(d, *a).ok_or(designcraft_doc::DocError::NoItem(*a))?; - let xf_b = to_inner(d, *b).ok_or(designcraft_doc::DocError::NoItem(*b))?.inverse(); - let m = inv_a * xf_b; - let mut pb = d.item(*b).and_then(|it| it.path.subpaths.get(sb).cloned()).ok_or(designcraft_doc::DocError::NoItem(*b))?; - for an in &mut pb.anchors { - *an = Anchor { p: m * an.p, h_in: m * an.h_in, h_out: m * an.h_out, kind: an.kind }; - } - let ita = d.item_mut(*a).ok_or(designcraft_doc::DocError::NoItem(*a))?; - let pa = ita.path.subpaths.get_mut(sa).ok_or_else(|| bad("path.join", "no such subpath"))?; - // The pair of ends nearest each other; then a's end meets b's start. - let ends = |sp: &designcraft_geom::SubPath| (sp.anchors.first().map(|x| x.p), sp.anchors.last().map(|x| x.p)); - let ((Some(a0), Some(a1)), (Some(b0), Some(b1))) = (ends(pa), ends(&pb)) else { - return Err(bad("path.join", "select two open paths")); - }; - let pairs = [(a1, b0, false, false), (a1, b1, false, true), (a0, b0, true, false), (a0, b1, true, true)]; - let Some(&(_, _, rev_a, rev_b)) = pairs.iter().min_by(|x, y| (x.0 - x.1).hypot().total_cmp(&(y.0 - y.1).hypot())) else { - return Err(bad("path.join", "select two open paths")); - }; - if rev_a { - pa.reverse(); - } - if rev_b { - pb.reverse(); - } - let mut rest = pb.anchors.into_iter(); - if let Some(first) = rest.next() { - match pa.anchors.last_mut() { - Some(last) if (last.p - first.p).hypot() < 0.01 => *last = Anchor::with_handles(last.p, last.h_in, first.h_out), - _ => pa.anchors.push(first), - } - } - pa.anchors.extend(rest); - as_path(&mut ita.shape); - d.remove_item(*b)?; - *sel = designcraft_doc::Selection::items(vec![*a]); - Ok(json!({"id": a.0})) - } + [id] => close_open_subpaths(d, *id), + [a, b] => join_open_pair(d, sel, *a, *b), _ => Err(bad("path.join", "select one or two open paths")), } }) diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge.rs similarity index 93% rename from crates/engine/src/cmd/datamerge/mod.rs rename to crates/engine/src/cmd/datamerge.rs index c07e2fff4..e96feb9fb 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge.rs @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, id_param, ok, str_param}; use crate::{DocState, Result, Session}; -use fill::{FillReport, Maps, MissingImage, copy_item, fill_row, identity_map}; +use fill::{FillReport, Maps, MissingImage, Sources, copy_item, fill_row, identity_map}; use parse::{ Table, TileInput, extension, fingerprint_of, relative_between, select_records, status_of, table_from_bytes, table_from_grid, table_from_objects, tile, @@ -210,7 +210,8 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default(); let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf); let doc_dir = st.path.as_deref().and_then(parent_dir); - fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref()); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + fill_row(&mut doc, &source.fields, &row, record as u32, &maps, &sources); st.doc = Arc::new(doc); st.preview_record = Some(record as u32); st.bump_revision(); @@ -245,10 +246,12 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { warnings.extend(table.warnings.iter().cloned()); let mut merged = src.clone(); let mut missing = Vec::new(); + let sources = Sources { options: &options, data_dir: data_dir.as_deref(), doc_dir: doc_dir.as_deref() }; + let mut sink = Sink { warnings: &mut warnings, missing: &mut missing, parent: false }; if options.per_page == "multiple" { - tile_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?; + tile_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?; } else { - copy_records(&mut merged, &src, &table, &selected, &options, data_dir.as_deref(), doc_dir.as_deref(), &mut warnings, &mut missing)?; + copy_records(&mut merged, &src, &table, &selected, &sources, &mut sink)?; } // Status is written only after every error return. The new document then becomes active, // so this template edit does not push an undo step. The fingerprint stays the last read. @@ -272,25 +275,14 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result { })) } -fn copy_records( - dst: &mut Document, - src: &Document, - table: &Table, - selected: &[usize], - options: &MergeOptions, - data_dir: Option<&Path>, - doc_dir: Option<&Path>, - warnings: &mut Vec, - missing: &mut Vec, -) -> Result<()> { - let mut parent = false; +fn copy_records(dst: &mut Document, src: &Document, table: &Table, selected: &[usize], sources: &Sources, sink: &mut Sink) -> Result<()> { if let Some(&idx) = selected.first() { let maps = identity_map(dst); - fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + fill_at(dst, table, idx, sources, &maps, sink); } for &idx in selected.iter().skip(1) { let maps = append_copy(dst, src)?; - fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + fill_at(dst, table, idx, sources, &maps, sink); } Ok(()) } @@ -320,17 +312,8 @@ fn append_copy(dst: &mut Document, src: &Document) -> Result { Ok(maps) } -fn tile_records( - dst: &mut Document, - src: &Document, - table: &Table, - selected: &[usize], - options: &MergeOptions, - data_dir: Option<&Path>, - doc_dir: Option<&Path>, - warnings: &mut Vec, - missing: &mut Vec, -) -> Result<()> { +fn tile_records(dst: &mut Document, src: &Document, table: &Table, selected: &[usize], sources: &Sources, sink: &mut Sink) -> Result<()> { + let options = sources.options; let (block, ids) = block_of(src); let (width, height) = src.page(0).map(|p| (p.width, p.height)).unwrap_or((612.0, 792.0)); let plan = tile(&TileInput { @@ -344,7 +327,7 @@ fn tile_records( count: selected.len(), }); if plan.one_per_page { - warnings.push("Only one record fits on the page, so each record has its own page.".into()); + sink.warnings.push("Only one record fits on the page, so each record has its own page.".into()); } if plan.pages > 1 { let parent = src.page(0).and_then(|p| p.parent); @@ -356,12 +339,11 @@ fn tile_records( } } } - let mut parent = false; for hit in plan.hits { let Some(&idx) = selected.get(hit.index) else { continue }; if hit.index == 0 && hit.page == 0 && hit.col == 0 && hit.row == 0 { let maps = identity_map(dst); - fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + fill_at(dst, table, idx, sources, &maps, sink); continue; } let Some((dsi, _)) = dst.page_loc(hit.page) else { continue }; @@ -370,39 +352,37 @@ fn tile_records( let Some(it) = src.item(*id) else { continue }; copy_item(dst, src, it, SpreadRef::Doc(dsi), Vec2::new(hit.dx, hit.dy), &mut maps)?; } - fill_at(dst, table, idx, options, &maps, data_dir, doc_dir, warnings, missing, &mut parent); + fill_at(dst, table, idx, sources, &maps, sink); } Ok(()) } -fn fill_at( - doc: &mut Document, - table: &Table, - idx: usize, - options: &MergeOptions, - maps: &Maps, - data_dir: Option<&Path>, - doc_dir: Option<&Path>, - warnings: &mut Vec, - missing: &mut Vec, - parent: &mut bool, -) { +fn fill_at(doc: &mut Document, table: &Table, idx: usize, sources: &Sources, maps: &Maps, sink: &mut Sink) { let Some(row) = table.rows.get(idx) else { return }; - let report = fill_row(doc, &table.fields, row, (idx as u32).saturating_add(1), options, maps, data_dir, doc_dir); - absorb(report, warnings, missing, parent); + let report = fill_row(doc, &table.fields, row, (idx as u32).saturating_add(1), maps, sources); + sink.absorb(report); } -fn absorb(report: FillReport, warnings: &mut Vec, missing: &mut Vec, parent: &mut bool) { - for w in report.warnings { - if w == PARENT_WARNING { - if *parent { - continue; +/// Warnings and missing images collected across every record, with the parent-page warning kept to one. +struct Sink<'a> { + warnings: &'a mut Vec, + missing: &'a mut Vec, + parent: bool, +} + +impl Sink<'_> { + fn absorb(&mut self, report: FillReport) { + for w in report.warnings { + if w == PARENT_WARNING { + if self.parent { + continue; + } + self.parent = true; } - *parent = true; + self.warnings.push(w); } - warnings.push(w); + self.missing.extend(report.missing); } - missing.extend(report.missing); } fn block_of(doc: &Document) -> (Rect, Vec) { diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs index d4b2235e9..629258d6d 100644 --- a/crates/engine/src/cmd/datamerge/fill.rs +++ b/crates/engine/src/cmd/datamerge/fill.rs @@ -128,21 +128,33 @@ struct Rep { text: String, } +/// Where `fill_row` looks for linked images: the merge options and the two search folders. +#[derive(Clone, Copy)] +pub struct Sources<'a> { + pub options: &'a MergeOptions, + pub data_dir: Option<&'a Path>, + pub doc_dir: Option<&'a Path>, +} + +/// Replacements and hyperlinks collected while walking the placeholders of one record. +#[derive(Default)] +struct Pending { + reps: Vec, + links: Vec<(PlaceholderAnchor, HyperlinkDest, String)>, + parent: bool, + report: FillReport, +} + /// Fill `row` (0-based) into the items `maps` points at. `record` is the 1-based source row. pub fn fill_row( doc: &mut designcraft_doc::Document, fields: &[DataField], row: &[String], record: u32, - options: &MergeOptions, maps: &Maps, - data_dir: Option<&Path>, - doc_dir: Option<&Path>, + sources: &Sources, ) -> FillReport { - let mut report = FillReport::default(); - let mut parent = false; - let mut reps: Vec = Vec::new(); - let mut links: Vec<(PlaceholderAnchor, HyperlinkDest, String)> = Vec::new(); + let mut pending = Pending::default(); let placeholders = doc.data_merge.placeholders.clone(); for ph in &placeholders { if !fields.iter().any(|f| f.name == ph.field) { @@ -150,43 +162,11 @@ pub fn fill_row( } let cell = cell_of(fields, row, &ph.field).to_string(); match &ph.anchor { - PlaceholderAnchor::Text { story, start, end } => { - let Some(&mapped) = maps.stories.get(story) else { - if anchor_is_parent(doc, &ph.anchor) { - parent = true; - } - continue; - }; - match ph.role { - PlaceholderRole::Text => reps.push(Rep { story: mapped, start: *start, end: *end, text: cell }), - PlaceholderRole::Hyperlink => { - if let Some(dest) = classify_link(&cell) { - links.push((PlaceholderAnchor::Text { story: mapped, start: *start, end: *end }, dest, ph.field.clone())); - } - } - PlaceholderRole::Image | PlaceholderRole::Qr => {} - } - } - PlaceholderAnchor::Item { id } => { - let Some(&mapped) = maps.items.get(id) else { - if anchor_is_parent(doc, &ph.anchor) { - parent = true; - } - continue; - }; - match ph.role { - PlaceholderRole::Image => place_image(doc, mapped, &ph.field, &cell, record, options, data_dir, doc_dir, &mut report), - PlaceholderRole::Qr => place_qr(doc, mapped, &ph.field, &cell, record, &mut report), - PlaceholderRole::Hyperlink => { - if let Some(dest) = classify_link(&cell) { - links.push((PlaceholderAnchor::Item { id: mapped }, dest, ph.field.clone())); - } - } - PlaceholderRole::Text => {} - } - } + PlaceholderAnchor::Text { story, start, end } => plan_text(doc, maps, ph, (*story, *start, *end), cell, &mut pending), + PlaceholderAnchor::Item { .. } => plan_item(doc, maps, ph, &cell, record, sources, &mut pending), } } + let Pending { mut reps, links, parent, mut report } = pending; typed_reps(doc, fields, row, maps, &placeholders, &mut reps); apply_reps(doc, &reps); for (anchor, dest, field) in links { @@ -207,6 +187,59 @@ pub fn fill_row( report } +fn plan_text( + doc: &designcraft_doc::Document, + maps: &Maps, + ph: &designcraft_doc::Placeholder, + (story, start, end): (StoryId, usize, usize), + cell: String, + pending: &mut Pending, +) { + let Some(&mapped) = maps.stories.get(&story) else { + if anchor_is_parent(doc, &ph.anchor) { + pending.parent = true; + } + return; + }; + match ph.role { + PlaceholderRole::Text => pending.reps.push(Rep { story: mapped, start, end, text: cell }), + PlaceholderRole::Hyperlink => { + if let Some(dest) = classify_link(&cell) { + pending.links.push((PlaceholderAnchor::Text { story: mapped, start, end }, dest, ph.field.clone())); + } + } + PlaceholderRole::Image | PlaceholderRole::Qr => {} + } +} + +fn plan_item( + doc: &mut designcraft_doc::Document, + maps: &Maps, + ph: &designcraft_doc::Placeholder, + cell: &str, + record: u32, + sources: &Sources, + pending: &mut Pending, +) { + let PlaceholderAnchor::Item { id } = &ph.anchor else { return }; + let Some(&mapped) = maps.items.get(id) else { + if anchor_is_parent(doc, &ph.anchor) { + pending.parent = true; + } + return; + }; + match ph.role { + PlaceholderRole::Image => place_image(doc, mapped, &ph.field, cell, record, sources, &mut pending.report), + PlaceholderRole::Qr => place_qr(doc, mapped, &ph.field, cell, record, &mut pending.report), + PlaceholderRole::Hyperlink => { + if let Some(dest) = classify_link(cell) { + pending.links.push((PlaceholderAnchor::Item { id: mapped }, dest, ph.field.clone())); + } + } + PlaceholderRole::Text => {} + } +} + fn typed_reps( doc: &designcraft_doc::Document, fields: &[DataField], @@ -326,17 +359,8 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d true } -fn place_image( - doc: &mut designcraft_doc::Document, - item: ItemId, - field: &str, - cell: &str, - record: u32, - options: &MergeOptions, - data_dir: Option<&Path>, - doc_dir: Option<&Path>, - report: &mut FillReport, -) { +fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) { + let Sources { options, data_dir, doc_dir } = *sources; let cands = image_candidates(cell, data_dir, doc_dir); let found = cands.iter().find_map(|p| { // Refuse huge files before reading them; a too-large image counts as missing. @@ -369,6 +393,7 @@ fn place_image( }; let mode = fitting_of(&options.fitting); let mut g = Graphic { + layer_options: None, asset: AssetId(0), size: (nw, nh), xf: Affine::translate((inner.x0, inner.y0)), diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs index c76c37f1e..6cc838c3c 100644 --- a/crates/engine/src/cmd/datamerge/parse.rs +++ b/crates/engine/src/cmd/datamerge/parse.rs @@ -128,7 +128,7 @@ pub fn table_from_objects(rows: &[Value]) -> Result { warnings.push(format!("Row {} has an extra field \"{key}\". It was ignored.", i + 1)); } } - if cells.iter().all(|c| c.is_empty()) { + if cells.iter().all(String::is_empty) { continue; } out.push(cells); @@ -159,38 +159,7 @@ pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: let i = record_index(one, count)?; idx.push(i); } - "range" => { - if range.trim().is_empty() { - return Err("no records".into()); - } - for token in range.split(',') { - let token = token.trim(); - if token.is_empty() { - continue; - } - if let Some((a, b)) = token.split_once('-') { - let a = parse_record_no(a.trim())?; - let b = parse_record_no(b.trim())?; - if a > b { - return Err(format!("range {a}-{b} is reversed")); - } - let mut n = a; - while n <= b { - let i = record_index(n, count)?; - if seen.insert(i) { - idx.push(i); - } - n = n.saturating_add(1); - } - } else { - let n = parse_record_no(token)?; - let i = record_index(n, count)?; - if seen.insert(i) { - idx.push(i); - } - } - } - } + "range" => select_range(range, count, &mut idx, &mut seen)?, other => return Err(format!("unknown records value \"{other}\"")), } if let Some(limit) = limit { @@ -205,6 +174,41 @@ pub fn select_records(count: usize, records: &str, one: u32, range: &str, limit: Ok(idx) } +/// Expand a comma-separated list of record numbers and `a-b` ranges into `idx`, skipping repeats. +fn select_range(range: &str, count: usize, idx: &mut Vec, seen: &mut std::collections::HashSet) -> Result<(), String> { + if range.trim().is_empty() { + return Err("no records".into()); + } + for token in range.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + let (a, b) = match token.split_once('-') { + Some((a, b)) => (parse_record_no(a.trim())?, parse_record_no(b.trim())?), + None => { + let n = parse_record_no(token)?; + (n, n) + } + }; + if a > b { + return Err(format!("range {a}-{b} is reversed")); + } + let mut n = a; + while n <= b { + let i = record_index(n, count)?; + if seen.insert(i) { + idx.push(i); + } + if n == b { + break; + } + n += 1; + } + } + Ok(()) +} + fn parse_record_no(s: &str) -> Result { s.parse::().map_err(|_| format!("bad record \"{s}\"")) } diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs index 98849eae1..79e16027e 100644 --- a/crates/engine/src/cmd/datamerge/tests.rs +++ b/crates/engine/src/cmd/datamerge/tests.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; -use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, SourceStatus}; +use designcraft_doc::{Content, DataField, DataFieldKind, DataSource, Delimiter, HyperlinkDest, Selection, SourceStatus, StoryId, TextSel}; use serde_json::{Value, json}; use super::parse::{self, parse_csv}; @@ -790,30 +790,31 @@ fn tiling_rows_first_bottom_inset_and_refusals() { assert_eq!(s.documents().len(), before); } +/// An IME delete-surrounding while a record is previewed edits the template, not the filled +/// record, and its undo step restores the template. #[test] -fn update_remove_preview_inline_rows_and_old_files() { - let dir = scratch("update"); - let csv = dir.join("people.csv"); - write(&csv, "Name\nAda\nGrace\n"); +fn an_ime_delete_ends_a_preview_and_edits_the_template() { let mut s = Session::new(); new_doc(&mut s, 1); - text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); - exec(&mut s, "data.source.select", json!({"path": csv})); - let id = exec(&mut s, "data.placeholder.add", json!({"field": "Name"}))["id"].clone(); - let undo = s.documents()[0].history.undo.len(); - let fingerprint = s.documents()[0].doc.data_merge.sources[0].fingerprint; - let mut body = std::fs::read(&csv).unwrap(); - body.extend_from_slice(b"Kai\n"); - std::fs::write(&csv, &body).unwrap(); - let r = exec(&mut s, "data.merge", json!({})); - assert_eq!(r["records"], 2); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("changed"))); - assert_eq!(texts(&s), vec!["Ada".to_string(), "Grace".to_string()]); - assert_eq!(s.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Modified); - assert_eq!(s.documents()[0].doc.data_merge.sources[0].fingerprint, fingerprint); - assert_eq!(s.documents()[0].history.undo.len(), undo); - assert_eq!(s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].status, SourceStatus::Modified); + let f = text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + let sid = StoryId(f["story"].as_u64().unwrap()); + select_csv(&mut s, "Name\nAda\nGrace\n"); + exec(&mut s, "data.placeholder.add", json!({"field": "Name"})); + assert_eq!(texts(&s), vec!["<>".to_string()]); + exec(&mut s, "data.preview", json!({"record": 2})); + assert_eq!(texts(&s), vec!["Grace".to_string()], "the preview shows the record"); + // The caret was placed in the preview, at the end of the filled text. + s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: 5, focus: 5, frame: None, cell: None }); + let m = s.ime_delete_surrounding(1, 0, false).unwrap().expect("the delete changed the template"); + assert!(m.inserted.is_empty()); + assert!(s.doc().unwrap().preview_stash.is_none(), "the preview ended before the edit"); + assert_eq!(texts(&s), vec!["<>".to_string()], "the template lost the character, not the filled record"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(texts(&s), vec!["<>".to_string()], "Undo restores the template, never the preview record"); + assert!(s.doc().unwrap().preview_stash.is_none()); +} +fn check_missing_merge_source(dir: &Path) { let gone = dir.join("gone.csv"); write(&gone, "Name\nAda\nGrace\n"); let mut missing = Session::new(); @@ -829,7 +830,9 @@ fn update_remove_preview_inline_rows_and_old_files() { assert_eq!(texts(&missing), vec!["Ada".to_string(), "Grace".to_string()]); assert_eq!(missing.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Missing); assert_eq!(missing.documents()[0].history.undo.len(), missing_undo); +} +fn check_refused_facing_page_merge(dir: &Path) { let refuse = dir.join("refuse.csv"); write(&refuse, "Name\nAda\nGrace\n"); let mut refused = Session::new(); @@ -846,49 +849,56 @@ fn update_remove_preview_inline_rows_and_old_files() { assert_eq!(refused.documents().len(), 1); assert_eq!(refused.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Ok); assert_eq!(refused.documents()[0].history.undo.len(), refuse_undo); +} - exec(&mut s, "file.activate", json!({"index": 0})); - exec(&mut s, "data.source.update", json!({})); - let r = exec(&mut s, "data.merge", json!({})); +fn check_preview_update_save(session: &mut Session, dir: &Path, csv: &Path, id: &Value) { + exec(session, "file.activate", json!({"index": 0})); + exec(session, "data.source.update", json!({})); + let r = exec(session, "data.merge", json!({})); assert_eq!(r["records"], 3); - assert!(texts(&s).iter().any(|t| t == "Kai")); + assert!(texts(session).iter().any(|t| t == "Kai")); - exec(&mut s, "file.activate", json!({"index": 0})); - exec(&mut s, "data.source.remove", json!({})); - assert!(s.documents()[0].doc.data_merge.sources.is_empty()); - assert!(s.documents()[0].doc.data_merge.placeholders.iter().any(|ph| ph.id == id.as_u64().unwrap())); - assert_eq!(exec(&mut s, "data.fields", json!({})), json!([])); + exec(session, "file.activate", json!({"index": 0})); + exec(session, "data.source.remove", json!({})); + assert!(session.documents()[0].doc.data_merge.sources.is_empty()); + assert!(session.documents()[0].doc.data_merge.placeholders.iter().any(|ph| ph.id == id.as_u64().unwrap())); + assert_eq!(exec(session, "data.fields", json!({})), json!([])); - select_csv(&mut s, "Name\nAda\nGrace\n"); - exec(&mut s, "data.preview", json!({"record": 2})); - assert!(texts(&s).iter().any(|t| t.contains("Grace"))); - assert!(!texts(&s).iter().any(|t| t.contains("<>"))); - let ser = exec(&mut s, "file.serialize", json!({})); + select_csv(session, "Name\nAda\nGrace\n"); + exec(session, "data.preview", json!({"record": 2})); + assert!(texts(session).iter().any(|t| t.contains("Grace"))); + assert!(!texts(session).iter().any(|t| t.contains("<>"))); + let ser = exec(session, "file.serialize", json!({})); let saved = crate::cmd::from_bytes(&crate::cmd::base64_decode(ser["base64"].as_str().unwrap())).unwrap(); assert!(saved.stories.values().any(|st| st.text.contains("<>"))); assert!(!saved.stories.values().any(|st| st.text.contains("Grace"))); - exec(&mut s, "data.preview.stop", json!({})); - assert!(texts(&s).iter().any(|t| t.contains("<>"))); + exec(session, "data.preview.stop", json!({})); + assert!(texts(session).iter().any(|t| t.contains("<>"))); - exec(&mut s, "data.preview", json!({"record": 2})); + exec(session, "data.preview", json!({"record": 2})); let path = dir.join("saved.designcraft"); - exec(&mut s, "file.save", json!({"path": path})); + exec(session, "file.save", json!({"path": path})); let saved = crate::cmd::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); assert!(!saved.stories.values().any(|st| st.text.contains("Grace"))); assert!(saved.stories.values().any(|st| st.text.contains("<>"))); - exec(&mut s, "data.preview", json!({"record": 2})); + exec(session, "data.preview", json!({"record": 2})); let rec = dir.join("recovery"); - s.recovery_dir = Some(rec.clone()); - crate::recovery::save(&s, &rec).unwrap(); - let uid = s.documents()[0].uid; + session.recovery_dir = Some(rec.clone()); + crate::recovery::save(session, &rec).unwrap(); + let uid = session.documents()[0].uid; let recovered = crate::cmd::from_bytes(&std::fs::read(rec.join(format!("{uid}.designcraft"))).unwrap()).unwrap(); assert!(!recovered.stories.values().any(|st| st.text.contains("Grace"))); - assert!(texts(&s).iter().any(|t| t.contains("Grace")), "recovery writes the stash and leaves the preview on screen"); - exec(&mut s, "file.new", json!({})); - exec(&mut s, "file.activate", json!({"index": 0})); - assert!(texts(&s).iter().any(|t| t.contains("<>"))); + assert!(texts(session).iter().any(|t| t.contains("Grace")), "recovery writes the stash and leaves the preview on screen"); + exec(session, "file.new", json!({})); + exec(session, "file.activate", json!({"index": 0})); + assert!(texts(session).iter().any(|t| t.contains("<>"))); + check_inline_rows_and_invalid_requests(); + check_saved_relative_source(dir, csv); +} + +fn check_inline_rows_and_invalid_requests() { let mut s = Session::new(); new_doc(&mut s, 1); text_frame(&mut s, [36.0, 36.0, 300.0, 80.0], "<> <>", None); @@ -916,7 +926,9 @@ fn update_remove_preview_inline_rows_and_old_files() { let msg = err(&mut s, "data.merge", json!({"csv": "Name\nAda\n", "limit": 4_294_967_296u64})); assert!(msg.contains("too large"), "{msg}"); assert_eq!(s.documents().len(), before); +} +fn check_saved_relative_source(dir: &Path, csv: &Path) { let fresh = dir.join("fresh.designcraft"); let mut s = Session::new(); new_doc(&mut s, 1); @@ -929,6 +941,35 @@ fn update_remove_preview_inline_rows_and_old_files() { assert_eq!(opened.data_merge.sources[0].relative_path.as_deref(), Some("people.csv")); } +#[test] +fn update_remove_preview_inline_rows_and_old_files() { + let dir = scratch("update"); + let csv = dir.join("people.csv"); + write(&csv, "Name\nAda\nGrace\n"); + let mut s = Session::new(); + new_doc(&mut s, 1); + text_frame(&mut s, [36.0, 36.0, 240.0, 80.0], "", None); + exec(&mut s, "data.source.select", json!({"path": csv})); + let id = exec(&mut s, "data.placeholder.add", json!({"field": "Name"}))["id"].clone(); + let undo = s.documents()[0].history.undo.len(); + let fingerprint = s.documents()[0].doc.data_merge.sources[0].fingerprint; + let mut body = std::fs::read(&csv).unwrap(); + body.extend_from_slice(b"Kai\n"); + std::fs::write(&csv, &body).unwrap(); + let r = exec(&mut s, "data.merge", json!({})); + assert_eq!(r["records"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("changed"))); + assert_eq!(texts(&s), vec!["Ada".to_string(), "Grace".to_string()]); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].status, SourceStatus::Modified); + assert_eq!(s.documents()[0].doc.data_merge.sources[0].fingerprint, fingerprint); + assert_eq!(s.documents()[0].history.undo.len(), undo); + assert_eq!(s.documents()[s.active_index().unwrap()].doc.data_merge.sources[0].status, SourceStatus::Modified); + + check_missing_merge_source(&dir); + check_refused_facing_page_merge(&dir); + check_preview_update_save(&mut s, &dir, &csv, &id); +} + #[test] fn parent_placeholder_is_copied_unfilled() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a10e9ae2f..890974f32 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -550,343 +550,7 @@ fn graphic_alone(frame: &designcraft_doc::Item, g: &designcraft_doc::Graphic, pa } #[cfg(test)] -mod text_clipboard_tests { - use super::*; - - fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story - } - - #[test] - fn paste_text_with_no_insertion_point_makes_a_text_frame() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); - // Text from another app, nothing copied here: a new, selected text frame. - let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); - let id = ItemId(r["id"].as_u64().unwrap()); - let d = s.doc().unwrap(); - assert_eq!(d.selection.items, vec![id]); - assert!(d.selection.text.is_none(), "selected as an object"); - let it = d.doc.item(id).unwrap(); - assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); - let page = &d.doc.spreads[0].pages[0]; - let (b, m) = (it.bounds(), page.margin_rect()); - assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); - assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); - assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); - assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(items(&s), 0, "one undo removes it"); - // Centred on `at`; hostile numbers are clamped. - let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); - let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); - assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); - assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); - s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); - // Copied objects paste when no text is given. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let n = items(&s); - let r = s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(r["ids"].as_array().unwrap().len(), 1); - assert_eq!(items(&s), n + 1); - assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); - s.doc().unwrap().doc.check().unwrap(); - } - - /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). - #[test] - fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let story = frame(&mut s, "ab"); - let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - let marker = copied_objects_text(&s); - s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); - assert!(r.get("anchored").is_none(), "{r}"); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); - // What the object copy put on the system clipboard still pastes the objects. - assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); - // With no insertion point too: the rectangle, not a text frame holding the marker. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); - let id = ItemId(r["ids"][0].as_u64().unwrap()); - assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); - s.doc().unwrap().doc.check().unwrap(); - // Paste without Formatting inserts the text given; empty text is no text. - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); - assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); - } - - /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. - #[test] - fn copied_text_frames_paste_at_a_caret_as_their_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let target = frame(&mut s, "ab"); - let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { - s.execute("selection.set", &json!({"ids": ids})).unwrap(); - s.execute("edit.copy", &json!({})).unwrap(); - s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("edit.paste", &json!({"text": text})).unwrap() - }; - let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); - // The same text copied in another app, and the system clipboard read back as absent. - let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); - assert!(r.get("anchored").is_none(), "{r}"); - s.execute("edit.paste", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); - // A group with a text frame inside, its own placeholder on the system clipboard. - let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); - let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); - let r = copy_and_paste(&mut s, json!([group]), Value::Null); - assert!(r.get("anchored").is_none(), "{r}"); - assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn outside_text_never_pastes_markers() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // Private-use characters from another app would read as footnote, table or variable markers. - let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; - let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); - let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); - s.doc().unwrap().doc.check().unwrap(); - let b = frame(&mut s, "Target"); - s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); - s.execute("edit.paste", &json!({"text": dirty})).unwrap(); - s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); - assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "Bold words\nSecond"); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); - s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); - let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - s.execute("edit.deselectAll", &json!({})).unwrap(); - let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); - let d = s.doc().unwrap(); - let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); - assert_eq!(st.text, "Bold words\nSecond"); - assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); - assert_eq!(st.char_format_at(7).over.font_style, None); - assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); - d.doc.check().unwrap(); - } - - #[test] - fn formatted_copy_paste_and_plain_paste() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "Bold words here."); - let b = frame(&mut s, "Target: ."); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - // A footnote inside the copied range travels with it. - s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); - s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); - let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); - let r = s.execute("edit.copy", &json!({})).unwrap(); - assert_eq!(r["text"], "Bold words"); - // Formatted paste when the system clipboard matches. - s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); - s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); - let st = s.doc().unwrap().doc.story(b).unwrap().clone(); - assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); - assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); - assert_eq!(st.notes.len(), 1); - assert_eq!(st.notes[0].text.text, "n"); - // Other text on the system clipboard pastes as plain text. - s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); - assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); - // Paste without formatting takes the insertion format. - s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); - let st = s.doc().unwrap().doc.story(b).unwrap().clone(); - assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); - assert_eq!(st.char_format_at(1).over.font_style, None); - // Cut removes and copies. - s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); - assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); - assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); - s.doc().unwrap().doc.check().unwrap(); - } - - fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let source = frame(&mut s, "aéz"); - s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); - s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); - s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); - s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); - assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); - (s, source) - } - - fn assert_child_note_paste( - s: &mut Session, - source: designcraft_doc::StoryId, - parent: designcraft_doc::StoryId, - cell: designcraft_doc::CellAddr, - endpoints: (usize, usize), - ) { - let before = s.doc().unwrap().doc.clone(); - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); - assert_eq!(selected.frame, None); - let before_child = before.text_story(parent, Some(cell)).unwrap(); - assert_eq!(before_child.text, "L----R"); - let clipboard = s.text_clipboard.clone(); - let history = s.execute("document.history", &json!({})).unwrap(); - assert_eq!(history["redo"], json!([])); - - let result = s.execute("edit.paste", &json!({})).unwrap(); - let after = s.doc().unwrap().doc.clone(); - let child = after.text_story(parent, Some(cell)).unwrap(); - assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); - assert_eq!(child.len(), 6); - assert_eq!(result, json!({"pos": 5, "formatted": true})); - assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); - assert!(child.endnotes.is_empty() && child.editorial.is_empty()); - for pos in [1, 2, 4] { - let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - assert_eq!(format.over.font_style.as_deref(), Some("Bold")); - } - // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. - for pos in [0, 5] { - let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); - assert_eq!(new_format, old_format); - } - assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); - assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); - assert_eq!(after.story(source), before.story(source)); - assert_eq!(s.text_clipboard, clipboard); - - let mut pasted_history = history.clone(); - pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); - assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); - assert_eq!(s.doc().unwrap().doc, before); - let mut undone_history = history; - undone_history["redo"] = json!(["Paste"]); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); - // This filter does not change which selection Undo restores. - assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); - assert_eq!(s.doc().unwrap().doc, after); - assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); - } - - #[test] - fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { - let (mut s, source) = note_clipboard_session(); - let parent = frame(&mut s, "PZ"); - s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); - let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); - s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); - s.execute("table.nextCell", &json!({})).unwrap(); - s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); - s.execute("table.prevCell", &json!({})).unwrap(); - s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - for _ in 0..4 { - s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); - } - let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); - let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; - assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); - let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; - assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); - let mut expected_table = (*before_table).clone(); - expected_table.cells[0].text = table.cells[0].text.clone(); - assert_eq!(table.as_ref(), &expected_table); - - // The same clipboard must still retain both markers when pasted into an ordinary root story. - let root = frame(&mut s, "L----R"); - s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); - assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); - assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); - } - - #[test] - fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { - let (mut s, source) = note_clipboard_session(); - let parent = frame(&mut s, "PZ"); - let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); - let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); - s.execute("text.move", &json!({"dir": "left"})).unwrap(); - for _ in 0..4 { - s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); - } - let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); - assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); - let after = s.doc().unwrap().doc.story(parent).unwrap(); - assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); - assert_eq!(after.note(neighbor), before.note(neighbor)); - assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); - } - - #[test] - fn change_case_keeps_runs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "the quick brown fox. jumps over straße"); - s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); - s.execute("type.italic", &json!({})).unwrap(); - let len = s.doc().unwrap().doc.story(a).unwrap().len(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); - s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); - s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); - s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); - let st = s.doc().unwrap().doc.story(a).unwrap().clone(); - assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); - assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); - assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); - st.check().unwrap(); - } - - #[test] - fn change_case_leaves_georgian_without_initial_capitals() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); - let len = s.doc().unwrap().doc.story(a).unwrap().len(); - s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); - // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. - s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); - s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); - s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); - } -} +mod text_clipboard_tests; fn paste_into(s: &mut Session, p: &Value) -> Result { use super::object::RemoveKeep; diff --git a/crates/engine/src/cmd/edit/text_clipboard_tests.rs b/crates/engine/src/cmd/edit/text_clipboard_tests.rs new file mode 100644 index 000000000..d947105cd --- /dev/null +++ b/crates/engine/src/cmd/edit/text_clipboard_tests.rs @@ -0,0 +1,335 @@ +use super::*; + +fn frame(s: &mut Session, text: &str) -> designcraft_doc::StoryId { + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story +} + +#[test] +fn paste_text_with_no_insertion_point_makes_a_text_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let items = |s: &Session| s.doc().unwrap().doc.spreads[0].items.len(); + // Text from another app, nothing copied here: a new, selected text frame. + let r = s.execute("edit.paste", &json!({"text": "From elsewhere\r\nSecond line"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap(); + assert_eq!(d.selection.items, vec![id]); + assert!(d.selection.text.is_none(), "selected as an object"); + let it = d.doc.item(id).unwrap(); + assert_eq!(d.doc.story(it.text_frame().unwrap().story).unwrap().text, "From elsewhere\nSecond line"); + let page = &d.doc.spreads[0].pages[0]; + let (b, m) = (it.bounds(), page.margin_rect()); + assert!((b.width() - page.column_rects()[0].width()).abs() < 1e-6, "{b:?}"); + assert!(b.height() < m.height() / 2.0, "fits two lines: {b:?}"); + assert!((b.center().y - m.center().y).abs() < 1e-6, "{b:?}"); + assert!(!s.cache.get(&d.doc, it.text_frame().unwrap().story, None).is_overset()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(items(&s), 0, "one undo removes it"); + // Centred on `at`; hostile numbers are clamped. + let r = s.execute("edit.paste", &json!({"text": "Here", "at": [200.0, 300.0]})).unwrap(); + let b = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 300.0).abs() < 1e-6, "{b:?}"); + assert!(s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300], "spread": 99})).is_err()); + s.execute("edit.paste", &json!({"text": "Far", "at": [1e300, -1e300]})).unwrap(); + // Copied objects paste when no text is given. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [72, 72, 200, 160]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let n = items(&s); + let r = s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(items(&s), n + 1); + assert!(s.doc().unwrap().doc.item(ItemId(r["ids"][0].as_u64().unwrap())).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); +} + +/// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). +#[test] +fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let story = frame(&mut s, "ab"); + let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let marker = copied_objects_text(&s); + s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); + // What the object copy put on the system clipboard still pastes the objects. + assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); + // With no insertion point too: the rectangle, not a text frame holding the marker. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); + let id = ItemId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + // Paste without Formatting inserts the text given; empty text is no text. + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); +} + +/// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. +#[test] +fn copied_text_frames_paste_at_a_caret_as_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let target = frame(&mut s, "ab"); + let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"text": text})).unwrap() + }; + let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); + // The same text copied in another app, and the system clipboard read back as absent. + let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); + assert!(r.get("anchored").is_none(), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); + // A group with a text frame inside, its own placeholder on the system clipboard. + let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); + let r = copy_and_paste(&mut s, json!([group]), Value::Null); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn outside_text_never_pastes_markers() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Private-use characters from another app would read as footnote, table or variable markers. + let dirty = "a\u{E00A}b\u{E009}c\u{E1FF}d\u{E200}"; + let r = s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + let st = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(st).unwrap().text, "abcd\u{E200}"); + s.doc().unwrap().doc.check().unwrap(); + let b = frame(&mut s, "Target"); + s.execute("text.select", &json!({"story": b.0, "anchor": 6, "focus": 6})).unwrap(); + s.execute("edit.paste", &json!({"text": dirty})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({"text": dirty})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(b).unwrap().text, "Targetabcd\u{E200}abcd\u{E200}"); + assert!(s.execute("edit.paste", &json!({"text": "\u{E00A}"})).is_err()); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn copied_text_pasted_with_no_insertion_point_keeps_its_formatting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words\nSecond"); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 11, "focus": 17})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center"}})).unwrap(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 17})).unwrap(); + let copied = s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": copied})).unwrap(); + let d = s.doc().unwrap(); + let st = d.doc.story(d.doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story).unwrap(); + assert_eq!(st.text, "Bold words\nSecond"); + assert_eq!(st.char_format_at(2).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.char_format_at(7).over.font_style, None); + assert_eq!(st.paras[1], d.doc.story(a).unwrap().paras[1]); + d.doc.check().unwrap(); +} + +#[test] +fn formatted_copy_paste_and_plain_paste() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "Bold words here."); + let b = frame(&mut s, "Target: ."); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + // A footnote inside the copied range travels with it. + s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 4})).unwrap(); + s.execute("footnote.insert", &json!({"text": "n"})).unwrap(); + let fl = designcraft_doc::FOOTNOTE_REF.len_utf8(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": 10 + fl})).unwrap(); + let r = s.execute("edit.copy", &json!({})).unwrap(); + assert_eq!(r["text"], "Bold words"); + // Formatted paste when the system clipboard matches. + s.execute("text.select", &json!({"story": b.0, "anchor": 8, "focus": 8})).unwrap(); + s.execute("edit.paste", &json!({"text": "Bold words"})).unwrap(); + let st = s.doc().unwrap().doc.story(b).unwrap().clone(); + assert_eq!(st.text, format!("Target: Bold{} words.", designcraft_doc::FOOTNOTE_REF)); + assert_eq!(st.char_format_at(9).over.font_style.as_deref(), Some("Bold")); + assert_eq!(st.notes.len(), 1); + assert_eq!(st.notes[0].text.text, "n"); + // Other text on the system clipboard pastes as plain text. + s.execute("edit.paste", &json!({"text": "\"x\""})).unwrap(); + assert!(s.doc().unwrap().doc.story(b).unwrap().text.contains("\"x\""), "no smart quotes on paste"); + // Paste without formatting takes the insertion format. + s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); + let st = s.doc().unwrap().doc.story(b).unwrap().clone(); + assert!(st.text.starts_with("Bold wordsTarget"), "{:?}", st.text); + assert_eq!(st.char_format_at(1).over.font_style, None); + // Cut removes and copies. + s.execute("text.select", &json!({"story": b.0, "anchor": 0, "focus": 4})).unwrap(); + assert_eq!(s.execute("edit.cut", &json!({})).unwrap()["text"], "Bold"); + assert!(s.doc().unwrap().doc.story(b).unwrap().text.starts_with(" wordsTarget")); + s.doc().unwrap().doc.check().unwrap(); +} + +fn note_clipboard_session() -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let source = frame(&mut s, "aéz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("endnote.insert", &json!({"text": "Citation A"})).unwrap(); + s.execute("note.new", &json!({"text": "Memo A", "author": "Ed"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(source).unwrap().text, "a\u{E00F}\u{E010}éz"); + s.execute("text.select", &json!({"story": source.0, "anchor": 0, "focus": 10})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold"}})).unwrap(); + assert_eq!(s.execute("edit.copy", &json!({})).unwrap(), json!({"text": "aéz"})); + (s, source) +} + +fn assert_child_note_paste( + s: &mut Session, + source: designcraft_doc::StoryId, + parent: designcraft_doc::StoryId, + cell: designcraft_doc::CellAddr, + endpoints: (usize, usize), +) { + let before = s.doc().unwrap().doc.clone(); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.cell, selected.anchor, selected.focus), (parent, Some(cell), endpoints.0, endpoints.1)); + assert_eq!(selected.frame, None); + let before_child = before.text_story(parent, Some(cell)).unwrap(); + assert_eq!(before_child.text, "L----R"); + let clipboard = s.text_clipboard.clone(); + let history = s.execute("document.history", &json!({})).unwrap(); + assert_eq!(history["redo"], json!([])); + + let result = s.execute("edit.paste", &json!({})).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let child = after.text_story(parent, Some(cell)).unwrap(); + assert_eq!(child.text, "LaézR", "root-only note markers must not enter child text"); + assert_eq!(child.len(), 6); + assert_eq!(result, json!({"pos": 5, "formatted": true})); + assert_eq!(s.doc().unwrap().selection, Selection::text(designcraft_doc::TextSel { anchor: 5, focus: 5, ..selected })); + assert!(child.endnotes.is_empty() && child.editorial.is_empty()); + for pos in [1, 2, 4] { + let (_, format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(format.over.font_style.as_deref(), Some("Bold")); + } + // Inspect actual run coverage: char_format_at at a run boundary means the preceding run. + for pos in [0, 5] { + let (_, old_format) = before_child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + let (_, new_format) = child.runs().find(|(range, _)| range.contains(&pos)).unwrap(); + assert_eq!(new_format, old_format); + } + assert_eq!(after.story(parent).unwrap().text, before.story(parent).unwrap().text); + assert_eq!(after.story(parent).unwrap().chars, before.story(parent).unwrap().chars); + assert_eq!(after.story(source), before.story(source)); + assert_eq!(s.text_clipboard, clipboard); + + let mut pasted_history = history.clone(); + pasted_history["undo"].as_array_mut().unwrap().push(json!("Paste")); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, before); + let mut undone_history = history; + undone_history["redo"] = json!(["Paste"]); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), undone_history); + // This filter does not change which selection Undo restores. + assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": "Paste"})); + assert_eq!(s.doc().unwrap().doc, after); + assert_eq!(s.execute("document.history", &json!({})).unwrap(), pasted_history); +} + +#[test] +fn formatted_child_paste_filters_endnotes_and_editorial_in_cells() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let table_id = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "L----R"})).unwrap(); + s.execute("table.nextCell", &json!({})).unwrap(); + s.execute("text.insert", &json!({"text": "NEIGHBOR"})).unwrap(); + s.execute("table.prevCell", &json!({})).unwrap(); + s.execute("text.move", &json!({"dir": "storyStart"})).unwrap(); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let before_table = s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id].clone(); + let cell = designcraft_doc::CellAddr { table: table_id, row: 0, col: 0 }; + assert_child_note_paste(&mut s, source, parent, cell, (1, 5)); + let table = &s.doc().unwrap().doc.story(parent).unwrap().tables[&table_id]; + assert_eq!(table.cell(0, 1).unwrap().text.text, "NEIGHBOR"); + let mut expected_table = (*before_table).clone(); + expected_table.cells[0].text = table.cells[0].text.clone(); + assert_eq!(table.as_ref(), &expected_table); + + // The same clipboard must still retain both markers when pasted into an ordinary root story. + let root = frame(&mut s, "L----R"); + s.execute("text.select", &json!({"story": root.0, "anchor": 1, "focus": 5})).unwrap(); + assert_eq!(s.execute("edit.paste", &json!({})).unwrap(), json!({"pos": 11, "formatted": true})); + assert_eq!(s.doc().unwrap().doc.story(root).unwrap().text, "La\u{E00F}\u{E010}ézR"); +} + +#[test] +fn formatted_child_paste_filters_endnotes_and_editorial_in_footnotes() { + let (mut s, source) = note_clipboard_session(); + let parent = frame(&mut s, "PZ"); + let neighbor = s.execute("footnote.insert", &json!({"text": "NEIGHBOR"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": parent.0, "anchor": 1, "focus": 1})).unwrap(); + let target = s.execute("footnote.insert", &json!({"text": "L----R"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + for _ in 0..4 { + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + } + let before = s.doc().unwrap().doc.story(parent).unwrap().clone(); + assert_child_note_paste(&mut s, source, parent, designcraft_doc::CellAddr::footnote(target), (5, 1)); + let after = s.doc().unwrap().doc.story(parent).unwrap(); + assert_eq!(after.note(neighbor).unwrap().text.text, "NEIGHBOR"); + assert_eq!(after.note(neighbor), before.note(neighbor)); + assert_eq!(after.notes.iter().map(|note| note.id).collect::>(), before.notes.iter().map(|note| note.id).collect::>()); +} + +#[test] +fn change_case_keeps_runs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "the quick brown fox. jumps over straße"); + s.execute("text.select", &json!({"story": a.0, "anchor": 4, "focus": 9})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The Quick Brown Fox. Jumps Over Straße"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "The quick brown fox. Jumps over straße"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + let st = s.doc().unwrap().doc.story(a).unwrap().clone(); + assert_eq!(st.text, "THE QUICK BROWN FOX. JUMPS OVER STRASSE"); + assert_eq!(st.char_format_at(5).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 0..st.len()); + st.check().unwrap(); +} + +#[test] +fn change_case_leaves_georgian_without_initial_capitals() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = frame(&mut s, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ word"); + let len = s.doc().unwrap().doc.story(a).unwrap().len(); + s.execute("text.select", &json!({"story": a.0, "anchor": 0, "focus": len})).unwrap(); + // Georgian has no title or sentence capitals: Mtavruli is only for all-caps text. + s.execute("type.changeCase", &json!({"case": "title"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი Word"); + s.execute("type.changeCase", &json!({"case": "sentence"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "გამარჯობა მსოფლიო. ახალი word"); + s.execute("type.changeCase", &json!({"case": "upper"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(a).unwrap().text, "ᲒᲐᲛᲐᲠᲯᲝᲑᲐ ᲛᲡᲝᲤᲚᲘᲝ. ᲐᲮᲐᲚᲘ WORD"); +} diff --git a/crates/engine/src/cmd/editorial.rs b/crates/engine/src/cmd/editorial.rs index 5b210da90..5b09cbcb6 100644 --- a/crates/engine/src/cmd/editorial.rs +++ b/crates/engine/src/cmd/editorial.rs @@ -2,11 +2,11 @@ use std::sync::Arc; -use designcraft_doc::{EditorialNote, NOTE_MARK, StoryId}; +use designcraft_doc::{ANCHOR_MARK, ENDNOTE_REF, EditorialNote, FOOTNOTE_REF, INDEX_MARK, NOTE_MARK, OBJECT_MARK, StoryId, TABLE_ANCHOR, XREF_MARK}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text, ok, str_param}; -use crate::Result; +use crate::{Result, Session}; pub fn specs() -> Vec { vec![ @@ -37,6 +37,15 @@ pub fn specs() -> Vec { Ok(json!({"story": ts.story.0, "id": id})) }) }), + cmd!( + "note.convertFromText", + "Convert to Note", + ["Type", "Notes"], + None, + "{author?} → {story, id}. The selected text moves into a new note anchored where it was.", + has_text_range, + convert_from_text + ), cmd!("note.edit", "Edit Note", [], None, "{story, id, text?, author?}", has_doc, |s, p| { let (sid, id) = target(p)?; let (text, author) = (str_param(p, "text").map(|_| super::text_param(p, "text")), str_param(p, "author").map(str::to_string)); @@ -56,9 +65,10 @@ pub fn specs() -> Vec { }), cmd!("note.delete", "Delete Note", ["Type", "Notes"], None, "{story, id}", has_doc, |s, p| { let (sid, id) = target(p)?; - s.edit(|d, _| { + s.edit(|d, sel| { let st = d.story_mut(sid).ok_or_else(|| bad("note.delete", "no such story"))?; let at = anchor(st, id).ok_or_else(|| bad("note.delete", format!("no note {id}")))?; + follow_replace(sel, sid, at..at + NOTE_MARK.len_utf8(), 0); st.delete(at..at + NOTE_MARK.len_utf8()); ok() }) @@ -72,15 +82,25 @@ pub fn specs() -> Vec { has_doc, |s, p| { let (sid, id) = target(p)?; - s.edit(|d, _| { + s.edit(|d, sel| { let st = d.story_mut(sid).ok_or_else(|| bad("note.convertToText", "no such story"))?; let at = anchor(st, id).ok_or_else(|| bad("note.convertToText", format!("no note {id}")))?; let text = st.editorial.iter().find(|n| n.id == id).map(|n| n.text.clone()).unwrap_or_default(); + follow_replace(sel, sid, at..at + NOTE_MARK.len_utf8(), text.len()); st.replace(at..at + NOTE_MARK.len_utf8(), &text); ok() }) } ), + cmd!( + "note.split", + "Split Note", + ["Type", "Notes"], + None, + "{story, id, at} → {story, id}. The note's text from byte `at` on moves into a new note anchored right after it.", + has_doc, + split + ), cmd!(query "note.list", "Notes", [], None, "{} → [{story, id, at, author, text}] in story order", has_doc, |s, _| { let d = &s.doc()?.doc; let mut out = Vec::new(); @@ -93,9 +113,112 @@ pub fn specs() -> Vec { } Ok(Value::Array(out)) }), + cmd!( + noundo "note.previous", + "Previous Note", + ["Type", "Notes"], + None, + "{} → {story, id, pos}. The caret moves after the anchor of the story's previous note.", + has_previous, + |s, _| go_to(s, false) + ), + cmd!( + noundo "note.next", + "Next Note", + ["Type", "Notes"], + None, + "{} → {story, id, pos}. The caret moves after the anchor of the story's next note.", + has_next, + |s, _| go_to(s, true) + ), ] } +/// Characters whose payload lives outside the story text: a note made from text that holds one +/// would lose it. +const MARKS: [char; 8] = [NOTE_MARK, ENDNOTE_REF, FOOTNOTE_REF, OBJECT_MARK, INDEX_MARK, ANCHOR_MARK, XREF_MARK, TABLE_ANCHOR]; + +fn has_text_range(s: &Session) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| d.selection.text) { + Some(t) if t.cell.is_some() => Err("notes go in story text".into()), + Some(t) if t.is_caret() => Err("select the text to convert".into()), + _ => Ok(()), + } +} + +/// Type › Notes › Convert to Note: the selected text leaves the story and becomes the text of a +/// new note, whose anchor takes the text's place. +fn convert_from_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "note.convertFromText"; + let ts = s.doc()?.selection.text.ok_or_else(|| bad(ID, "select the text to convert"))?; + if ts.cell.is_some() { + return Err(bad(ID, "notes go in story text")); + } + let r = ts.range(); + if r.is_empty() { + return Err(bad(ID, "select the text to convert")); + } + let author = str_param(p, "author").unwrap_or("").to_string(); + s.edit(|d, sel| { + let story = d.story_mut(ts.story).ok_or_else(|| bad(ID, "no such story"))?; + let text = story.text.get(r.clone()).ok_or_else(|| bad(ID, "the selection is outside the story"))?.to_string(); + if text.contains(MARKS) { + return Err(bad(ID, "the selection holds a table, an anchored object, a note, a footnote, an endnote or a marker")); + } + let fmt = story.format_after(r.start).clone(); + story.delete(r.clone()); + story.insert_with(r.start, &NOTE_MARK.to_string(), fmt); + story.fix_marks(); + let k = story.text[..r.start].matches(NOTE_MARK).count(); + let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (text, author); + let id = n.id; + if let Some(t) = &mut sel.text { + let c = r.start + NOTE_MARK.len_utf8(); + (t.anchor, t.focus) = (c, c); + } + Ok(json!({"story": ts.story.0, "id": id})) + }) +} + +/// Split Note (the Notes panel's Split Note button): the text after the split point leaves the note +/// and becomes the text of a new note by the same author, whose anchor directly follows the note's anchor. +fn split(s: &mut Session, p: &Value) -> Result { + const ID: &str = "note.split"; + let (sid, id) = target(p)?; + let at = p.get("at").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`at` required"))?; + s.edit(|d, sel| { + let story = d.story_mut(sid).ok_or_else(|| bad(ID, "no such story"))?; + let pos = anchor(story, id).ok_or_else(|| bad(ID, format!("no note {id}")))?; + let note = story.editorial.iter().find(|n| n.id == id).cloned().ok_or_else(|| bad(ID, format!("no note {id}")))?; + let at = usize::try_from(at).ok().filter(|&a| a > 0 && a < note.text.len() && note.text.is_char_boundary(a)); + let at = at.ok_or_else(|| bad(ID, "place the split point inside the note's text"))?; + let (head, tail) = note.text.split_at(at); + let (head, tail) = (head.to_string(), tail.to_string()); + let ins = pos + NOTE_MARK.len_utf8(); + let fmt = story.char_format_at(pos).clone(); + story.insert_with(ins, &NOTE_MARK.to_string(), fmt); + story.fix_marks(); + let k = story.text[..ins].matches(NOTE_MARK).count(); + let first = story.editorial.get_mut(k - 1).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + Arc::make_mut(first).text = head; + let n = story.editorial.get_mut(k).ok_or_else(|| bad(ID, "the story's notes don't match their anchors"))?; + let n = Arc::make_mut(n); + (n.text, n.author) = (tail, note.author.clone()); + let new = n.id; + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + for c in [&mut t.anchor, &mut t.focus] { + if *c >= ins { + *c += NOTE_MARK.len_utf8(); + } + } + } + Ok(json!({"story": sid.0, "id": new})) + }) +} + fn target(p: &Value) -> Result<(StoryId, u64)> { let sid = p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("note", "`story` required"))?; let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("note", "`id` required"))?; @@ -108,6 +231,63 @@ fn anchor(st: &designcraft_doc::Story, id: u64) -> Option { st.text.match_indices(NOTE_MARK).nth(k).map(|(i, _)| i) } +/// Keeps a caret or selection in story `sid`'s own text (not a cell) on the same characters when `range` is replaced +/// by `len` bytes: an end after the range moves by the change in length, and an end inside it moves to its start. +fn follow_replace(sel: &mut designcraft_doc::Selection, sid: StoryId, range: std::ops::Range, len: usize) { + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + let follow = |i: usize| if i >= range.end { i - range.len() + len } else { i.min(range.start) }; + t.anchor = follow(t.anchor); + t.focus = follow(t.focus); + } +} + +/// The anchor offset and id of the note before or after the selection `t` in its story. Going +/// back skips the note whose anchor the caret directly follows, so repeated steps reach every note. +fn neighbor(st: &designcraft_doc::Story, t: &designcraft_doc::TextSel, forward: bool) -> Option<(usize, u64)> { + let r = t.range(); + let mut anchors = st.text.match_indices(NOTE_MARK).enumerate().map(|(k, (i, _))| (k, i)); + let (k, at) = if forward { anchors.find(|&(_, i)| i >= r.end) } else { anchors.filter(|&(_, i)| i + NOTE_MARK.len_utf8() < r.start).last() }?; + Some((at, st.editorial.get(k)?.id)) +} + +fn has_note(s: &crate::Session, forward: bool) -> std::result::Result<(), String> { + has_text(s)?; + match s.active().and_then(|d| Some((d, d.selection.text?))) { + Some((_, t)) if t.cell.is_some() => Err("notes go in story text".into()), + Some((d, t)) if d.doc.story(t.story).and_then(|st| neighbor(st, &t, forward)).is_some() => Ok(()), + _ => Err(no_note(forward).into()), + } +} + +fn has_previous(s: &crate::Session) -> std::result::Result<(), String> { + has_note(s, false) +} + +fn has_next(s: &crate::Session) -> std::result::Result<(), String> { + has_note(s, true) +} + +fn no_note(forward: bool) -> &'static str { + if forward { "no later note in this story" } else { "no earlier note in this story" } +} + +/// Type › Notes › Previous Note and Next Note: from the insertion point, the caret moves after +/// the anchor of the story's previous or next note. +fn go_to(s: &mut crate::Session, forward: bool) -> Result { + let id = if forward { "note.next" } else { "note.previous" }; + let st = s.doc_mut()?; + let t = st.selection.text.ok_or_else(|| bad(id, "place the insertion point in text"))?; + if t.cell.is_some() { + return Err(bad(id, "notes go in story text")); + } + let (at, note) = st.doc.story(t.story).and_then(|x| neighbor(x, &t, forward)).ok_or_else(|| bad(id, no_note(forward)))?; + let pos = at + NOTE_MARK.len_utf8(); + st.selection.text = Some(designcraft_doc::TextSel::caret(t.story, pos)); + st.bump_revision(); + s.typing_format = None; + Ok(json!({"story": t.story.0, "id": note, "pos": pos})) +} + #[cfg(test)] mod tests { use serde_json::json; @@ -136,6 +316,46 @@ mod tests { assert!(s.doc().unwrap().doc.stories[&sid].editorial.is_empty()); } + #[test] + fn previous_and_next_note_step_through_the_story_s_notes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two three."})).unwrap(); + let len = designcraft_doc::NOTE_MARK.len_utf8(); + let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": r["story"], "anchor": at, "focus": at})).unwrap(); + caret(&mut s, 3); + let first = s.execute("note.new", &json!({"text": "a"})).unwrap(); + caret(&mut s, 7 + len); + let second = s.execute("note.new", &json!({"text": "b"})).unwrap(); + caret(&mut s, 0); + let disabled = |s: &mut Session, id: &str| matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))); + assert!(disabled(&mut s, "note.previous"), "no note before the start"); + let n = s.execute("note.next", &json!({})).unwrap(); + assert_eq!((n["id"].clone(), n["pos"].clone()), (first["id"].clone(), json!(3 + len))); + let n = s.execute("note.next", &json!({})).unwrap(); + assert_eq!((n["id"].clone(), n["pos"].clone()), (second["id"].clone(), json!(7 + 2 * len))); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7 + 2 * len, 7 + 2 * len), "the caret follows the anchor"); + assert!(disabled(&mut s, "note.next"), "no note after the last"); + // From just after the second anchor, Previous Note goes to the first one. + let n = s.execute("note.previous", &json!({})).unwrap(); + assert_eq!(n["id"], first["id"]); + assert!(disabled(&mut s, "note.previous")); + // From the middle of the story, each direction finds its neighbor. + caret(&mut s, 5 + len); + assert_eq!(s.execute("note.previous", &json!({})).unwrap()["id"], first["id"]); + caret(&mut s, 5 + len); + assert_eq!(s.execute("note.next", &json!({})).unwrap()["id"], second["id"]); + // Formatting set at the insertion point does not survive the move. + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + assert!(s.typing_format.is_some()); + s.execute("note.previous", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "note.previous"); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("note.next", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "note.next"); + } + #[test] fn notes_insert_into_a_story_whose_markers_lack_payloads() { let mut s = Session::new(); @@ -159,4 +379,129 @@ mod tests { assert_eq!(st.endnotes[1].text.text, "An endnote."); assert_eq!(st.notes[1].text.text, "A footnote."); } + + #[test] + fn selected_text_converts_to_a_note_at_its_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One two\nthree four."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mark = designcraft_doc::NOTE_MARK.len_utf8(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + s.execute("note.new", &json!({"text": "earlier"})).unwrap(); + // "two\nthree " across the paragraph return, selected backwards. + let (a, b) = (3 + mark + 1, 3 + mark + 1 + "two\nthree ".len()); + s.execute("text.select", &json!({"story": r["story"], "anchor": b, "focus": a})).unwrap(); + let n = s.execute("note.convertFromText", &json!({"author": "Ed"})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + st.check().unwrap(); + assert_eq!(st.text, format!("One{m} {m}four.", m = designcraft_doc::NOTE_MARK)); + assert_eq!(st.paras.len(), 1, "the paragraph return went into the note"); + let list = s.execute("note.list", &json!({})).unwrap(); + assert_eq!(list[0]["text"], "earlier"); + assert_eq!((list[1]["id"].clone(), list[1]["at"].clone()), (n["id"].clone(), json!(a))); + assert_eq!((list[1]["text"].as_str(), list[1]["author"].as_str()), (Some("two\nthree "), Some("Ed"))); + let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)); + assert_eq!(caret, Some((a + mark, a + mark)), "the insertion point follows the anchor"); + s.execute("note.convertToText", &json!({"story": n["story"], "id": n["id"]})).unwrap(); + assert_eq!(s.doc().unwrap().doc.stories[&sid].text, format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + assert_eq!((st.text.as_str(), st.editorial.len()), (format!("One{} two\nthree four.", designcraft_doc::NOTE_MARK).as_str(), 1)); + } + + #[test] + fn split_note_moves_the_text_after_the_split_point_into_a_new_note() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mark = designcraft_doc::NOTE_MARK.len_utf8(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let n = s.execute("note.new", &json!({"text": "check this", "author": "Ed"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 9 + mark, "focus": 9 + mark})).unwrap(); + let later = s.execute("note.new", &json!({"text": "later"})).unwrap(); + // The caret sits after "Draft" and its anchor, before " copy". + s.execute("text.select", &json!({"story": r["story"], "anchor": 5 + mark, "focus": 5 + mark})).unwrap(); + let before = s.doc().unwrap().doc.stories[&sid].clone(); + let split = |s: &mut Session, at: u64| s.execute("note.split", &json!({"story": n["story"], "id": n["id"], "at": at})); + for at in [0, 10, 11, 99] { + assert!(matches!(split(&mut s, at), Err(crate::EngineError::BadParams { .. })), "split point {at}"); + } + assert_eq!(s.doc().unwrap().doc.stories[&sid], before); + let m = split(&mut s, 6).unwrap(); + let st = &s.doc().unwrap().doc.stories[&sid]; + st.check().unwrap(); + assert_eq!(st.text, format!("Draft{m}{m} cop{m}y.", m = designcraft_doc::NOTE_MARK)); + let list = s.execute("note.list", &json!({})).unwrap(); + let row = |k: usize| (list[k]["id"].clone(), list[k]["at"].clone(), list[k]["text"].clone(), list[k]["author"].clone()); + assert_eq!(row(0), (n["id"].clone(), json!(5), json!("check "), json!("Ed"))); + assert_eq!(row(1), (m["id"].clone(), json!(5 + mark), json!("this"), json!("Ed")), "the new note follows the first"); + assert_eq!(row(2).0, later["id"], "the later note keeps its text"); + assert_eq!(list[2]["text"], "later"); + let caret = s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)); + assert_eq!(caret, Some((5 + 2 * mark, 5 + 2 * mark)), "the caret stays before \" copy\""); + // Multibyte text splits on character boundaries only. + let e = s.execute("note.new", &json!({"text": "été"})).unwrap(); + let refused = s.execute("note.split", &json!({"story": e["story"], "id": e["id"], "at": 1})); + assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "inside a character"); + s.execute("note.split", &json!({"story": e["story"], "id": e["id"], "at": 2})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.stories[&sid], before, "undo restores the one note"); + } + + #[test] + fn convert_to_note_needs_selected_story_text_without_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let disabled = |r| matches!(r, Err(crate::EngineError::Disabled(..))); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "an insertion point selects no text"); + s.execute("note.new", &json!({"text": "keep"})).unwrap(); + let before = s.doc().unwrap().doc.stories[&sid].clone(); + let all = before.len(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": all})).unwrap(); + let refused = s.execute("note.convertFromText", &json!({})); + assert!(matches!(refused, Err(crate::EngineError::BadParams { .. })), "a note inside the selection would be lost"); + assert_eq!(s.doc().unwrap().doc.stories[&sid], before); + s.execute("text.select", &json!({"story": r["story"], "anchor": all, "focus": all})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": "cell", "raw": true})).unwrap(); + s.execute("text.move", &json!({"dir": "left", "extend": true})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some_and(|t| t.cell.is_some() && !t.is_caret())); + assert!(disabled(s.execute("note.convertFromText", &json!({}))), "notes go in story text"); + } + + #[test] + fn deleting_or_converting_a_note_keeps_the_caret_on_its_character() { + let m = designcraft_doc::NOTE_MARK.len_utf8(); + // (anchor, focus) in "Draft{note} copy." before the command, then (anchor, focus) and the selected text after + // Delete Note and after Convert Note to Text with the note's text "ab". + let cases = [ + ((6 + m, 10 + m), ((6, 10), "copy"), ((8, 12), "copy")), + ((0, 10 + m), ((0, 10), "Draft copy"), ((0, 12), "Draftab copy")), + ((2, 4), ((2, 4), "af"), ((2, 4), "af")), + ((5 + m, 5 + m), ((5, 5), ""), ((7, 7), "")), + ]; + for (before, deleted, converted) in cases { + for (cmd, (after, selected)) in [("note.delete", deleted), ("note.convertToText", converted)] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Draft copy."})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": r["story"], "anchor": 5, "focus": 5})).unwrap(); + let n = s.execute("note.new", &json!({"text": "ab"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": before.0, "focus": before.1})).unwrap(); + s.execute(cmd, &json!({"story": n["story"], "id": n["id"]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), after, "{cmd} with {before:?} selected"); + assert_eq!(&s.doc().unwrap().doc.stories[&sid].text[t.anchor..t.focus], selected, "{cmd} with {before:?} selected"); + } + } + } } diff --git a/crates/engine/src/cmd/endnotes.rs b/crates/engine/src/cmd/endnotes.rs index 7bcae744b..9b02d2c87 100644 --- a/crates/engine/src/cmd/endnotes.rs +++ b/crates/engine/src/cmd/endnotes.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { }), cmd!("endnote.delete", "Delete Endnote", [], None, "{story, id} — removes the reference and its text", has_doc, |s, p| { let (sid, id) = target(p)?; - s.edit(|d, _| { + s.edit(|d, sel| { let st = d.story_mut(sid).ok_or_else(|| bad("endnote.delete", "no such story"))?; let k = st.endnotes.iter().position(|n| n.id == id).ok_or_else(|| bad("endnote.delete", format!("no endnote {id}")))?; let at = st @@ -40,6 +40,7 @@ pub fn specs() -> Vec { .nth(k) .map(|(i, _)| i) .ok_or_else(|| bad("endnote.delete", format!("endnote {id} has no reference in the text")))?; + super::notes::follow_delete(sel, sid, at..at + designcraft_doc::ENDNOTE_REF.len_utf8()); st.delete(at..at + designcraft_doc::ENDNOTE_REF.len_utf8()); ok() }) @@ -50,7 +51,7 @@ pub fn specs() -> Vec { for st in d.stories.values().filter(|st| !st.endnotes.is_empty()) { let first = d.endnote_start(st.id); for (k, n) in st.endnotes.iter().enumerate() { - out.push(json!({"story": st.id.0, "id": n.id, "number": first + k as u32, "text": n.text.text})); + out.push(json!({"story": st.id.0, "id": n.id, "number": first.saturating_add(k as u32), "text": n.text.text})); } } out.sort_by_key(|v| v["number"].as_u64()); @@ -172,4 +173,21 @@ mod tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc.stories[&esid].text, "Notes\ni\tEarlier.\nii\tEdited."); } + + #[test] + fn deleting_an_endnote_keeps_the_caret_on_its_character() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Alpha beta."})).unwrap(); + let sid = designcraft_doc::StoryId(a["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": a["story"], "anchor": 5, "focus": 5})).unwrap(); + let r = s.execute("endnote.insert", &json!({"text": "Source."})).unwrap(); + let n = designcraft_doc::ENDNOTE_REF.len_utf8(); + // "beta" is selected after the reference. + s.execute("text.select", &json!({"story": a["story"], "anchor": 6 + n, "focus": 10 + n})).unwrap(); + s.execute("endnote.delete", &json!({"story": r["story"], "id": r["id"]})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (6, 10)); + assert_eq!(&s.doc().unwrap().doc.stories[&sid].text[t.anchor..t.focus], "beta"); + } } diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index 551dd7484..a4ee73ce4 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -7,1381 +7,17 @@ use std::{borrow::Cow, sync::Arc}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{EngineError, Result, Session}; -pub fn specs() -> Vec { - vec![ - cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})", - has_doc, export_pdf), - cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, - "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", - has_doc, export_epub), - cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, - "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", - has_doc, |s, p| { - export_epub(s, &super::with_param(p, "fixedLayout", serde_json::json!(true))) - }), - cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, - "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", - has_doc, print_booklet), - cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, - "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})", - has_doc, export_html), - cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, - "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", - has_doc, export_pptx), - cmd!(noundo "file.exportText", "Export Text…", ["File"], None, - "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", - has_story_target, export_text), - ] -} - -fn print_booklet(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.printBooklet"; - let kind = match str_param(p, "type").unwrap_or("saddleStitch") { - "saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch, - "twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive, - t => return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))), - }; - let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; - let st = s.doc()?; - let r = designcraft_pdf::export_booklet(&super::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) - .map_err(|e| EngineError::Other(e.to_string()))?; - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), - } -} - -/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact. -fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) { - // Match the exporter's reading order (including Article exclusions), not every asset - // in the document: unused or hidden graphics must not prevent an export. - let mut used = std::collections::BTreeSet::new(); - for block in designcraft_epub::reading_order(d) { - let designcraft_epub::Block::Image(id) = block else { continue }; - let Some(it) = d.item(id) else { continue }; - let designcraft_doc::Content::Graphic(g) = &it.content else { continue }; - used.insert(g.asset); - if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some()) - && let Some(poster) = it.media.as_ref().and_then(|m| m.poster) - { - used.insert(poster); - } - } - let mut out = Cow::Borrowed(d); - let mut warnings = Vec::new(); - for id in used { - let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { continue }; - let png = designcraft_images::eps_png(&a.data); - if png.is_none() { - warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name)); - } - if let Some(a) = out.to_mut().assets.get_mut(&id) { - let a = Arc::make_mut(a); - a.mime = "image/png".into(); - a.data = Arc::new(png.unwrap_or_default()); - } - } - (out, warnings) -} - -fn export_html(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = designcraft_epub::HtmlOptions { - title: str_param(p, "title").map(str::to_string), - language: str_param(p, "language").map(str::to_string), - generic_families: generic_families(&st.doc), - }; - let (doc, warnings) = eps_previews(&st.doc); - let text = designcraft_epub::export_html(&doc, &opts); - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings})) - } - None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})), - } -} - -fn export_pptx(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.exportPptx"; - let st = s.doc()?; - let line_breaks = match str_param(p, "lineBreaks") { - None | Some("keep") => designcraft_pptx::LineBreaks::Keep, - Some("reflow") => designcraft_pptx::LineBreaks::Reflow, - Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), - }; - let opts = designcraft_pptx::PptxOptions { - pages: pages_param(ID, p, st.doc.page_count())?, - line_breaks, - title: str_param(p, "title").map(str::to_string), - author: str_param(p, "author").map(str::to_string), - created: None, - embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), - }; - // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame - // stays text: a picture of it would drop the rest of its story). - let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); - let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| { - !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) - }); - let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); - let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - if rasterized > 0 { - r.warnings.push(format!("{rasterized} object(s) went in as images")); - } - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), - } -} - -fn has_story_target(s: &Session) -> std::result::Result<(), String> { - super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) -} - -fn export_text(s: &mut Session, p: &Value) -> Result { - const ID: &str = "file.exportText"; - let sid = super::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?; - let doc = &s.doc()?.doc; - let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?; - let path = str_param(p, "path"); - let format = match str_param(p, "format") { - Some(f @ ("rtf" | "tagged")) => f, - Some("txt") | Some("text") => "txt", - Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))), - None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf", - None => "txt", - }; - let text = match format { - "rtf" => designcraft_textimport::export::rtf(doc, story), - "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), - _ => designcraft_textimport::export::plain_text(story), - }; - match path { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": text.len()})) - } - None => Ok(json!({"bytes": text.len(), "text": text})), - } -} - -/// Text of the frames on page `abs`, in line order (for fixed-layout pages). -fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { - let mut out = String::new(); - for st in d.stories.values() { - let cs = cache.get(d, st.id, None); - for ft in &cs.frames { - if d.page_of_item(ft.frame) != Some(abs) { - continue; - } - for l in &ft.lines { - let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); - let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); - if !t.trim().is_empty() { - out.push_str(t.trim_end_matches('\n')); - out.push('\n'); - } - } - } - } - out -} - -/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' -/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. -fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { - use designcraft_fonts::FontClass; - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut out = std::collections::BTreeMap::new(); - for (family, _) in designcraft_epub::stylesheet_fonts(d) { - if out.contains_key(&family) { - continue; - } - let generic = match db.family_kind(&family).map(|(k, _)| k.class) { - Some(FontClass::Serif | FontClass::SlabSerif) => "serif", - Some(FontClass::SansSerif) => "sans-serif", - Some(FontClass::Monospaced) => "monospace", - Some(FontClass::Script) => "cursive", - Some(FontClass::Decorative) => "fantasy", - Some(FontClass::Symbol | FontClass::Other) | None => continue, - }; - out.insert(family, generic.to_string()); - } - out -} - -/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out -/// (not available, a licence that forbids embedding, or part of a font collection). -fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut out = Vec::new(); - for (family, style) in designcraft_epub::stylesheet_fonts(d) { - let face = db.face(&family, &style); - if !face.family.eq_ignore_ascii_case(&family) { - warnings.push(format!("font `{family} {style}` is not available; not embedded")); - } else if face.restricted_licence() || face.bitmap_only() { - warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); - } else if face.data().starts_with(b"ttcf") { - warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); - } else { - out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); - } - } - out -} - -fn export_epub(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - // Object Export Options › Rasterize: those objects go in as images. - let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); - let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); - let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); - let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let mut warnings: Vec = Vec::new(); - // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. - let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { - Some(path) => Some(super::datamerge::read_path_capped(path, super::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?), - None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), - }; - if let Some(c) = &cover - && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) - { - return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); - } - let opts = designcraft_epub::EpubOptions { - title: p.get("title").and_then(Value::as_str).map(str::to_string), - author: p.get("author").and_then(Value::as_str).map(str::to_string), - language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), - identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), - cover, - toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), - generic_families: generic_families(&st.doc), - fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { - embedded_fonts(&st.doc, &mut warnings) - } else { - Vec::new() - }, - // The export time; on the web the document's own save time stands in. - #[cfg(not(target_arch = "wasm32"))] - modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), - #[cfg(target_arch = "wasm32")] - modified: None, - }; - if fixed { - let d = &st.doc; - let mut pages = Vec::new(); - for i in 0..d.page_count() { - let Some((si, pi)) = d.page_loc(i) else { continue }; - let b = d.spreads[si].pages[pi].bounds(); - let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { continue }; - pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); - } - let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; - return match p.get("path").and_then(Value::as_str) { - Some(path) => { - write_file(path, &bytes)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) - } - None => Ok(serde_json::json!({"base64": super::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), - }; - } - let (epub_doc, eps_warnings) = eps_previews(epub_doc); - warnings.extend(eps_warnings); - let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; - warnings.extend( - designcraft_epub::images_without_alt(&epub_doc) - .into_iter() - .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")), - ); - match p.get("path").and_then(Value::as_str) { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; - Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) - } - None => Ok(serde_json::json!({"base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), - } -} - -/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. -fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { - Ok(match p.get("pages") { - None | Some(Value::Null) => None, - Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), - Some(Value::Array(a)) => { - let mut v = Vec::new(); - for x in a { - let n = x - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .filter(|n| *n >= 1 && *n <= page_count) - .ok_or_else(|| bad(id, format!("bad page {x}")))?; - // A page named twice is one page, as `"2,2"` already is: the list says which - // pages to export, not how many times. `[2, 2]` exported two copies of page 2 - // while `"2,2"` exported one. - if !v.contains(&(n - 1)) { - v.push(n - 1); - } - } - Some(v) - } - Some(Value::Number(n)) => { - let n = n - .as_u64() - .and_then(|n| usize::try_from(n).ok()) - .filter(|n| *n >= 1 && *n <= page_count) - .ok_or_else(|| bad(id, format!("bad page {n}")))?; - Some(vec![n - 1]) - } - Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), - }) -} - -pub(crate) fn options(p: &Value, page_count: usize) -> Result { - const ID: &str = "file.exportPdf"; - - let pages = pages_param(ID, p, page_count)?; - let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); - let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); - let marks = match p.get("marks") { - Some(Value::Bool(true)) => Marks::ALL, - Some(Value::Object(m)) => { - let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); - Marks { - crop: b("crop"), - bleed: b("bleed"), - page_info: b("pageInfo"), - weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25), - offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0), - } - } - _ => Marks::NONE, - }; - let standard = match str_param(p, "standard") { - Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?, - None => Standard::None, - }; - let created = match p.get("created") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), - }; - Ok(PdfOptions { - pages, - spreads, - bleed, - marks, - standard, - compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), - title: str_param(p, "title").map(str::to_string), - author: str_param(p, "author").map(str::to_string), - tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), - media: p.get("media").and_then(Value::as_bool).unwrap_or(false), - created, - ..PdfOptions::default() - }) -} - -/// Does this object (or anything in it) need the transparency flattener? -fn transparent(it: &designcraft_doc::Item) -> bool { - it.involves_transparency() -} - -/// Transparency Flattener: each object involving transparency becomes an opaque image of its -/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items -/// involve transparency are rasterised whole. Works on a copy of the document made for export. -/// Returns the copy and the number of rasterised regions. -fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) { - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; - let mut out = d.clone(); - let mut n = 0; - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi / 72.0; - let parent_transparent = |pid: Option| { - pid.and_then(|id| d.parent_index(id)) - .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) - }; - // An opaque image item showing `img` over `r`. - let image_item = - |out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| { - let (w, h) = (img.width, img.height); - let aid = AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(Asset { - id: aid, - name: format!("flattened-{}.png", aid.0), - mime: "image/png".into(), - data: std::sync::Arc::new(img.to_png()), - pixels: Some((w, h)), - ..Default::default() - }), - ); - let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); - it.content = Content::Graphic(Graphic { - asset: aid, - size: (w as f64, h as f64), - xf: designcraft_geom::Affine::translate((r.x0, r.y0)) - * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - it.stroke.weight = 0.0; - it - }; - for (si, sp) in d.spreads.iter().enumerate() { - let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent)); - if whole { - // Parent items involve transparency: every page of the spread as one image. - let first = d.first_page_of_spread(si); - let lid = out.default_layer(); - let mut images = Vec::new(); - for (pi, pg) in sp.pages.iter().enumerate() { - if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) { - images.push((pg.bounds(), img)); - } - } - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - osp.items.clear(); - for pg in &mut osp.pages { - pg.show_parent_items = false; - } - for (b, img) in images { - let it = image_item(&mut out, b, img, lid); - let _ = out.insert_item(SpreadRef::Doc(si), it, None); - n += 1; - } - continue; - } - // Region by region, bottom to top. - for (idx, it) in sp.items.iter().enumerate() { - if it.hidden || !transparent(it) { - continue; - } - let reach = it.bounds().inflate( - designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, - designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, - ); - // Only the part on the spread's pages and pasteboard that prints. - let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); - if w as u64 * h as u64 > 80_000_000 { - continue; - } - // The stack up to and including this object. - let mut below = d.clone(); - std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1); - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; - let img = rr.render( - &below, - cache, - &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], - w, - h, - view, - &opts, - ); - let new = image_item(&mut out, reach, img, it.layer); - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { continue }; - if threaded(d, it) { - // Keep the frame (its story flows on) under the image. - osp.items.insert(pos + 1, std::sync::Arc::new(new)); - } else { - osp.items[pos] = std::sync::Arc::new(new); - } - n += 1; - } - } - (out, n) -} - -/// A text frame in a story that runs through other frames (replacing it would reflow the story). -fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { - it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) -} - -/// Soft effects other than the gradient feather (which PDF export draws itself). -fn raster_effects(it: &designcraft_doc::Item) -> bool { - let e = &it.effects; - // Knockout groups too: PDF output here has no knockout transparency groups. - it.knockout - || e.drop_shadow.on - || e.feather > 0.0 - || e.inner_shadow.on - || e.outer_glow.on - || e.inner_glow.on - || e.bevel.on - || e.satin.on - || e.directional_feather.on -} - -/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole -/// appearance (transparent around them, so they composite over what's behind). -fn rasterize_effects(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache) -> (Option, usize) { - rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it)) -} - -/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at -/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a -/// nonprinting [`wrap_stand_in`], so text wraps around it as before. -fn rasterize_where( - d: &designcraft_doc::Document, - cache: &designcraft_compose::Cache, - ppi: f64, - keep_wrap: bool, - pred: impl Fn(&designcraft_doc::Item) -> bool, -) -> (Option, usize) { - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; - let todo: Vec<(usize, ItemId)> = - d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect(); - if todo.is_empty() { - return (None, 0); - } - let mut out = d.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = designcraft_render::default_threads(); - let k = ppi / 72.0; - let mut n = 0; - for (si, id) in todo { - let Some(it) = d.item(id) else { continue }; - let reach = it.bounds().inflate( - designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, - designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, - ); - // Just this object on its spread, no paper. - let mut solo = d.clone(); - let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); - sp.items.retain(|x| x.id == id); - for pg in &mut sp.pages { - pg.show_parent_items = false; - } - let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); - if w as u64 * h as u64 > 60_000_000 { - continue; - } - let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); - let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; - let img = rr.render( - &solo, - cache, - &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], - w, - h, - view, - &opts, - ); - let aid = AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(Asset { - id: aid, - name: format!("effects-{}.png", id.0), - mime: "image/png".into(), - data: std::sync::Arc::new(img.to_png()), - pixels: Some((w, h)), - ..Default::default() - }), - ); - let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); - img_item.content = Content::Graphic(Graphic { - asset: aid, - size: (w as f64, h as f64), - xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - img_item.stroke.weight = 0.0; - img_item.alt_text = it.alt_text.clone(); - img_item.export_options = it.export_options.clone(); - // Same place in the stacking order. - let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); - if let Some(pos) = osp.items.iter().position(|x| x.id == id) { - osp.items[pos] = std::sync::Arc::new(img_item); - if keep_wrap && has_wrap(it, 0) { - osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0))); - } - n += 1; - } - } - (Some(out), n) -} - -/// Whether `it`, its placed graphic or an item in it wraps text. -fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool { - use designcraft_doc::{Content, WrapMode}; - if depth > MAX_STAND_IN_DEPTH { - return false; - } - it.wrap.mode != WrapMode::None - || match &it.content { - Content::Graphic(g) => g.wrap.mode != WrapMode::None, - Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)), - _ => false, - } -} - -/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins. -const MAX_STAND_IN_DEPTH: usize = 64; - -/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and -/// nothing else that reaches the PDF: no text, form field, button or media. The image of the -/// object stands in for all of that, as it does without the copy. -fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item { - use designcraft_doc::Content; - let mut c = it.clone(); - c.nonprinting = true; - c.form_field = None; - c.button = None; - c.media = None; - c.content = match &it.content { - Content::Text(_) => Content::Unassigned, - Content::Group { items } if depth < MAX_STAND_IN_DEPTH => { - Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() } - } - Content::Group { .. } => Content::Group { items: Vec::new() }, - other => other.clone(), - }; - c -} - -/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. -fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { - let ids: Vec = - d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect(); - if ids.is_empty() { - return Ok((None, 0)); - } - let mut out = d.clone(); - let mut n = 0; - for id in ids { - let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue }; - let Some(a) = out.assets.get(&g.asset).cloned() else { continue }; - if !designcraft_render::is_pdf(&a.data) { - continue; - } - // The whole page at 300 ppi (the graphic may show only a box of it). - let shown = designcraft_render::shown_box(&a); - let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); - let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t)); - let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; - let png = designcraft_render::pdf_page_png_checked(&a.data, a.page as usize, side, &hidden, shown) - .map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?; - let px = designcraft_render::image_size(&png); - let aid = designcraft_doc::AssetId(out.alloc()); - out.assets.insert( - aid, - std::sync::Arc::new(designcraft_doc::Asset { - id: aid, - name: format!("{}.png", a.name), - mime: "image/png".into(), - data: std::sync::Arc::new(png), - pixels: px, - ..Default::default() - }), - ); - if let Some(designcraft_doc::Content::Graphic(gg)) = out.item_mut(id).map(|it| &mut it.content) { - gg.asset = aid; - } - n += 1; - } - Ok((Some(out), n)) -} - -/// Object number of the first page of a PDF (its page tree's first kid). -fn first_page_ref(pdf: &[u8]) -> Option { - let s = String::from_utf8_lossy(pdf); - let i = s.find("/Kids[")? + 6; - s[i..].split_whitespace().next()?.parse().ok() -} - -fn export_pdf(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let opts = options(p, st.doc.page_count())?; - // Transparency Flattener presets: High / Medium / Low Resolution, or a ppi. - let ppi = match p.get("flatten") { - None | Some(Value::Null) | Some(Value::Bool(false)) => None, - Some(Value::String(s)) if s == "high" => Some(300.0), - Some(Value::String(s)) if s == "medium" => Some(150.0), - Some(Value::String(s)) if s == "low" => Some(72.0), - Some(Value::Bool(true)) => Some(300.0), - Some(v) => Some( - v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad("file.exportPdf", "`flatten`: high|medium|low or 36–1200 ppi"))?, - ), - }; - let flattened = ppi.map(|ppi| flatten(&st.doc, &s.cache, ppi)); - // Placed PDFs with hidden layers go out as images showing just their visible layers. - let base: &designcraft_doc::Document = flattened.as_ref().map_or(&st.doc, |f| &f.0); - let (layered, layer_count) = rasterize_layered(base)?; - let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); - let (fx_doc, fx_count) = rasterize_effects(base2, &s.cache); - let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); - let boxed = super::interchange::with_pdf_boxes(doc); - let doc: &designcraft_doc::Document = &boxed; - let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let mut r = r; - // Transparency Blend Space: the page group of pages with transparency. - let spreads = designcraft_pdf::sheet_spreads(doc, &opts); - let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); - if blended.iter().any(|b| *b) { - match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { - Some(b) => r.bytes = b, - None => r.warnings.push("the transparency blend space couldn't be set".into()), - } - } - // Interactive PDF view options. - let mut cat = String::new(); - if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/FullScreen"); - } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { - cat.push_str("/PageMode/UseOutlines"); - } - match str_param(p, "pageLayout") { - Some("single") => cat.push_str("/PageLayout/SinglePage"), - Some("continuous") => cat.push_str("/PageLayout/OneColumn"), - Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), - Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), - Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), - Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), - None => {} - } - if let Some(v) = str_param(p, "view") { - // The first page opens with this view (pages are objects in order, so refer by index 0). - let dest = match v { - "fitPage" => "/Fit", - "fitWidth" => "/FitH null", - "actual" => "/XYZ null null 1", - o => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))), - }; - if let Some(first) = first_page_ref(&r.bytes) { - cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); - } - } - if !cat.is_empty() { - match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { - Some(b) => r.bytes = b, - None => r.warnings.push("the PDF view options couldn't be set".into()), - } - } - // Flip pages every N seconds (presentation). - if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) { - let n = designcraft_pdf::sheet_spreads(doc, &opts).len(); - let dur: Vec> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); - match designcraft_pdf::add_page_entries(&r.bytes, &dur) { - Some(b) => r.bytes = b, - None => r.warnings.push("page timings couldn't be set".into()), - } - } - // Page transitions (each spread's first page holds them). - let trans: Vec> = - spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect(); - if trans.iter().any(Option::is_some) { - match designcraft_pdf::add_transitions(&r.bytes, &trans) { - Some(b) => r.bytes = b, - None => r.warnings.push("page transitions couldn't be added to this PDF".into()), - } - } - if fx_count > 0 { - r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) or knockout groups exported as images")); - } - if layer_count > 0 { - r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); - } - if let Some((_, n)) = &flattened - && *n > 0 - { - r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); - } - match str_param(p, "path") { - Some(path) => { - write_file(path, &r.bytes)?; - Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) - } - None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), - } -} +mod flatten; +mod formats; +mod pdf; +mod registry; +#[cfg(test)] +use flatten::{rasterize_effects, shadow_coverage}; #[cfg(not(target_arch = "wasm32"))] -fn write_file(path: &str, bytes: &[u8]) -> Result<()> { - crate::io::write_file(path, bytes).map_err(EngineError::Other) -} -#[cfg(target_arch = "wasm32")] -fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes"))) -} +pub(crate) use pdf::pdf_export; +pub(crate) use pdf::{flatten_ppi, options, print_pdf}; +pub use registry::specs; #[cfg(test)] -mod text_tests { - use serde_json::json; - - use crate::Session; - - fn unzip(b64: &str) -> zip::ZipArchive>> { - zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() - } - - fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { - let mut s = String::new(); - std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); - s - } - - #[test] - fn object_export_options() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // A plain rectangle rasterised for EPUB, centred, after a page break. - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); - s.execute( - "object.exportOptions", - &json!({"ids": [r["id"]], "rasterize": true, "align": "center", "pageBreakBefore": true, "altText": "A dot"}), - ) - .unwrap(); - let e = s.execute("file.exportEpub", &json!({})).unwrap(); - let mut z = unzip(e["base64"].as_str().unwrap()); - let x = read(&mut z, "OEBPS/content.xhtml"); - assert!( - x.contains("
")); - assert!(z.by_name("OEBPS/images/cover.png").is_ok()); - let r = s.execute("file.exportFixedEpub", &json!({})).unwrap(); - assert_eq!(r["pages"], 2); - let mut z = unzip(r["base64"].as_str().unwrap()); - let opf = read(&mut z, "OEBPS/content.opf"); - assert!(opf.contains("rendition:layout\">pre-paginated") && opf.contains("page2.xhtml")); - let p1 = read(&mut z, "OEBPS/page1.xhtml"); - assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); - } - - /// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts - /// (and reports those it can't). - #[test] - fn epub_font_fallbacks_and_embedding() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}})) - .unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); - let r = s.execute("file.exportEpub", &json!({})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let css = read(&mut z, "OEBPS/style.css"); - assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}"); - assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked"); - let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let css = read(&mut z, "OEBPS/style.css"); - assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}"); - let opf = read(&mut z, "OEBPS/content.opf"); - let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}")); - assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}"); - let warnings = r["warnings"].as_array().unwrap(); - assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}"); - let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(html.contains("\"Source Sans 3\", sans-serif;")); - } - - /// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole, - /// and a Semibold style asks for the Semibold face's weight, not Bold's. - #[test] - fn epub_embedding_follows_bitmap_only_licences_and_weights() { - use designcraft_fonts::testing::{font_with, with_fs_type}; - const BITMAP: &str = "DocFont Epub Bitmap Only"; - for fs_type in [0x0200, 0x0208] { - designcraft_fonts::FontDb::global() - .add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); - } - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - for fs_type in [0x0200, 0x0208] { - let family = format!("{BITMAP} {fs_type:04x}"); - s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap(); - } - s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); - let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let css = read(&mut z, "OEBPS/style.css"); - assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}"); - let warnings = r["warnings"].to_string(); - for fs_type in [0x0200, 0x0208] { - assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}"); - } - let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default(); - assert!(semi.contains("font-weight: 600;"), "{css}"); - } - - /// #771, #773: a cover image file and an identifier through the command; the package's - /// modified time is the export's. - #[test] - fn epub_cover_image_file_and_identifier() { - let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let jpg = dir.join("cover.jpg"); - std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap(); - let bad = dir.join("cover.txt"); - std::fs::write(&bad, b"not an image").unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); - let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap(); - let mut z = unzip(r["base64"].as_str().unwrap()); - let opf = read(&mut z, "OEBPS/content.opf"); - assert!(opf.contains("urn:isbn:9780000000002"), "{opf}"); - assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}"); - assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}"); - assert!(z.by_name("OEBPS/images/cover.jpg").is_ok()); - assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err()); - std::fs::remove_dir_all(&dir).ok(); - } - - #[test] - fn flattener_rasterises_spreads_with_transparency() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); - // Opaque: nothing to flatten. - let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); - assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("flattened"))); - s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); - // Text away from the transparency stays live text. - s.execute("frame.create", &json!({"rect": [72, 400, 400, 450], "content": "text", "text": "Still vector", "caret": false})).unwrap(); - let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); - let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - assert!(String::from_utf8_lossy(&pdf).contains("/FontFile"), "the text is still text"); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened: 1 ")), "{r}"); - assert_eq!(r["pages"], 3); - let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3)); - // The document itself is untouched. - assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some()); - assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err()); - // Interactive view options land in the catalog and on the pages. - let r = s.execute("file.exportPdf", &json!({"fullScreen": true, "pageLayout": "single", "view": "fitPage", "advanceSeconds": 5})).unwrap(); - let t = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); - assert!( - t.contains("/PageMode/FullScreen") && t.contains("/PageLayout/SinglePage") && t.contains("/OpenAction[") && t.contains("/Dur 5.00"), - "view options" - ); - assert!(s.execute("file.exportPdf", &json!({"pageLayout": "sideways"})).is_err()); - // Unflattened, the page blends in the document's blend space (CMYK for print). - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); - assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency"); - } - - #[test] - fn objects_with_soft_effects_keep_their_text_wrap_in_pdf() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Words wrap around the objects on this page. ".repeat(60); - let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"] - .as_u64() - .unwrap(); - // A feathered frame with a contour wrap of the frame itself. - let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap(); - s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap(); - // A feathered frame whose placed graphic has a bounding-box wrap. - let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png(); - let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap(); - s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap(); - s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap(); - s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap(); - - let d = &s.doc().unwrap().doc; - let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story; - let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone(); - assert_eq!(before.len(), 2); - let (fx, n) = super::rasterize_effects(d, &s.cache); - assert_eq!(n, 2); - let fx = fx.unwrap(); - assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before); - // The objects that keep the wrap don't print; their images do. - let sp = &fx.spreads[0]; - assert_eq!(sp.items.iter().filter(|it| it.nonprinting).count(), 2); - assert_eq!(sp.items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2); - } - - #[test] - fn export_story_as_text_and_rtf() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap(); - assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo"); - let html = s.execute("file.exportHtml", &json!({})).unwrap(); - assert!(html["text"].as_str().unwrap().contains("One

")); - let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap(); - assert!(tagged["text"].as_str().unwrap().contains(" 1000); - let fonts = s.execute("font.list", &json!({})).unwrap(); - assert!(fonts.to_string().contains("Black"), "{fonts}"); - } -} - -#[cfg(test)] -mod booklet_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn booklet_imposes_pages_in_pairs() { - use designcraft_pdf::{BookletKind, booklet_pairs}; - assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]); - assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks"); - assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap(); - assert_eq!(r["sheets"], 2); - let pdf = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).into_owned(); - let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default(); - assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}"); - assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err()); - // Page 1's black box lands on the right half of the first sheet (page 4 is on the left). - let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); - let r = s.execute("file.printBooklet", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised"); - let (w, h) = (px.width() as f64, px.height() as f64); - let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16); - let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0)); - assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}"); - assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); - } -} - -#[cfg(test)] -mod placed_pdf_print_tests { - use serde_json::json; - - use crate::Session; - use crate::cmd::{base64_decode, base64_encode}; - - /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. - fn logo() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - base64_decode(r["base64"].as_str().unwrap()) - } - - fn page_with(pdf: &[u8]) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); - s - } - - /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with - /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". - #[test] - fn pdf_17_placed_in_pdfx4() { - let logo = logo(); - assert!(logo.starts_with(b"%PDF-1.7")); - let mut s = page_with(&logo); - let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); - assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); - assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); - // PDF 2.0 can't simply be relabelled: the export fails and names the file. - let mut v20 = logo.clone(); - v20[5..8].copy_from_slice(b"2.0"); - let mut s = page_with(&v20); - let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); - assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); - } - - /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. - fn catalog_v17(header: &str) -> Vec { - let content = "0 0 0 1 k 0 0 200 200 re f"; - let mut pdf = format!("{header}\n").into_bytes(); - let mut offsets = Vec::new(); - for body in [ - "<>".to_owned(), - "<>".to_owned(), - "<>>>".to_owned(), - format!("<>\nstream\n{content}\nendstream", content.len()), - ] { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); - } - let xref = pdf.len(); - pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); - for offset in offsets { - pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); - } - pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - pdf - } - - /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export - /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). - #[test] - fn catalog_version_17_placed_in_pdfx4() { - for header in ["%PDF-1.7", "%PDF-1.4"] { - let pdf = catalog_v17(header); - assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); - let mut s = page_with(&pdf); - let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); - assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); - let out = base64_decode(r["base64"].as_str().unwrap()); - assert!(out.starts_with(b"%PDF-1.6")); - assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); - } - } -} - -#[cfg(test)] -mod print_group_tests { - use serde_json::json; - - use crate::Session; - use crate::cmd::{base64_decode, base64_encode}; - - /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. - fn logo() -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - base64_decode(r["base64"].as_str().unwrap()) - } - - fn page_with(pdf: &[u8]) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); - s - } - - /// The placed PDF's transparency group blended in DeviceRGB in a print document - /// (`/Group<>`). - #[test] - fn print_transparency_groups_blend_in_cmyk() { - let mut s = page_with(&logo()); - let text = |s: &mut Session| { - let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap(); - String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned() - }; - let t = text(&mut s); - assert!(t.contains("/S/Transparency/I true/CS/DeviceCMYK>>"), "CMYK group"); - assert!(!t.contains("DeviceRGB"), "no RGB left"); - assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes())); - // The file still reads, with the same page. - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - let bytes = base64_decode(r["base64"].as_str().unwrap()); - assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); - // An RGB blend space keeps RGB groups. - s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); - let t = text(&mut s); - assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); - } -} - -#[cfg(test)] -mod pptx_tests { - use serde_json::json; - - use crate::Session; - - fn unzip(b64: &str) -> zip::ZipArchive>> { - zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() - } - - fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { - let mut s = String::new(); - std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); - s - } - - #[test] - fn sample_exports_editable_slides() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - let r = s.execute("file.exportPptx", &json!({})).unwrap(); - assert_eq!(r["slides"], 4); - let mut z = unzip(r["base64"].as_str().unwrap()); - // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. - assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); - assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); - let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); - assert!(text.contains("Notes on the Grid"), "{text}"); - assert!(read(&mut z, "ppt/presentation.xml").contains("")); - - let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); - assert_eq!(r["slides"], 2); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); - assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); - assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); - } - - #[test] - fn rasterize_option_places_a_picture() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); - let shapes = |s: &mut Session| { - let r = s.execute("file.exportPptx", &json!({})).unwrap(); - read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") - }; - assert!(shapes(&mut s).contains("prst=\"ellipse\"")); - s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); - let x = shapes(&mut s); - assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); - } -} - -#[cfg(test)] -mod page_list_tests { - use serde_json::{Value, json}; - - use crate::Session; - - fn doc(pages: u64) -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); - s - } - - /// The same selection written two ways exported different files: `"2,2"` is one page, because - /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. - /// The list says which pages to export, not how many times. - #[test] - fn a_page_named_twice_is_exported_once_either_way() { - let mut s = doc(4); - for (pages, want) in [ - (json!("2,2"), 1), - (json!([2, 2]), 1), - (json!([2, 2, 2]), 1), - (json!("2-2,2"), 1), - (json!([1, 2, 1]), 2), - (json!("1,2,1"), 2), - (json!([4, 1]), 2), - (json!("4,1"), 2), - (json!("all"), 4), - ] { - let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); - assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); - let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); - assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); - } - // The two forms now agree byte for byte, and the pages come out in the document's order - // whichever order the list names them in (which is how the export already behaved). - let mut file = |pages: Value| { - let r = s.execute("file.exportPdf", &json!({"pages": pages})).unwrap(); - r["base64"].as_str().unwrap_or_default().to_string() - }; - assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); - assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); - assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); - // A page that does not exist is still refused, and an empty list still exports nothing. - assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); - assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); - } -} +mod text_tests; diff --git a/crates/engine/src/cmd/export/flatten.rs b/crates/engine/src/cmd/export/flatten.rs new file mode 100644 index 000000000..b32d54383 --- /dev/null +++ b/crates/engine/src/cmd/export/flatten.rs @@ -0,0 +1,608 @@ +use super::{EngineError, Result}; + +/// Does this object (or anything in it) need the transparency flattener? +pub(super) fn transparent(it: &designcraft_doc::Item) -> bool { + it.involves_transparency() +} + +/// Transparency Flattener: each object involving transparency becomes an opaque image of its +/// area as it looks over what's beneath it (so the rest stays vector); spreads whose parent items +/// involve transparency are rasterised whole. Works on a copy of the document made for export. +/// Returns the copy and the number of rasterised regions. +pub(super) fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, ppi: f64) -> (designcraft_doc::Document, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let mut out = d.clone(); + let mut n = 0; + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi / 72.0; + let parent_transparent = |pid: Option| { + pid.and_then(|id| d.parent_index(id)) + .is_some_and(|pi| d.parent_chain(pi).iter().filter_map(|&i| d.parents.get(i)).any(|p| p.items.iter().any(|it| transparent(it)))) + }; + // An opaque image item showing `img` over `r`. + let image_item = + |out: &mut designcraft_doc::Document, r: designcraft_geom::Rect, img: designcraft_render::Rendered, layer: designcraft_doc::LayerId| { + let (w, h) = (img.width, img.height); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + id: aid, + name: format!("flattened-{}.png", aid.0), + mime: "image/png".into(), + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + ..Default::default() + }), + ); + let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r)); + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((r.x0, r.y0)) + * designcraft_geom::Affine::scale_non_uniform(r.width() / w as f64, r.height() / h as f64), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + it.stroke.weight = 0.0; + it + }; + for (si, sp) in d.spreads.iter().enumerate() { + let whole = sp.pages.iter().any(|pg| pg.show_parent_items && parent_transparent(pg.parent)); + if whole { + // Parent items involve transparency: every page of the spread as one image. + let first = d.first_page_of_spread(si); + let lid = out.default_layer(); + let mut images = Vec::new(); + for (pi, pg) in sp.pages.iter().enumerate() { + if let Some(img) = rr.render_page(d, cache, first + pi, k, false, &Default::default()) { + images.push((pg.bounds(), img)); + } + } + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + osp.items.clear(); + for pg in &mut osp.pages { + pg.show_parent_items = false; + } + for (b, img) in images { + let it = image_item(&mut out, b, img, lid); + let _ = out.insert_item(SpreadRef::Doc(si), it, None); + n += 1; + } + continue; + } + // Region by region, bottom to top. + for (idx, it) in sp.items.iter().enumerate() { + if it.hidden || !transparent(it) { + continue; + } + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 1.0, + ); + // Only the part on the spread's pages and pasteboard that prints. + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 80_000_000 { + continue; + } + // The stack up to and including this object. + let mut below = d.clone(); + std::sync::Arc::make_mut(&mut below.spreads[si]).items.truncate(idx + 1); + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let img = rr.render( + &below, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], + (w, h), + view, + &opts, + ); + let new = image_item(&mut out, reach, img, it.layer); + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { + continue; + }; + if threaded(d, it) { + // Keep the frame (its story flows on) under the image. + osp.items.insert(pos + 1, std::sync::Arc::new(new)); + } else { + osp.items[pos] = std::sync::Arc::new(new); + } + n += 1; + } + } + (out, n) +} + +/// A text frame in a story that runs through other frames (replacing it would reflow the story). +pub(super) fn threaded(d: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { + it.text_frame().and_then(|t| d.story(t.story)).is_some_and(|st| st.frames.len() > 1) +} + +/// Soft effects other than the gradient feather (which PDF export draws itself). +fn raster_effects(it: &designcraft_doc::Item) -> bool { + let e = &it.effects; + e.drop_shadow.on + || e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on +} + +/// A copy of `d` where spread-level objects with soft effects are 300 ppi images of their whole +/// appearance (transparent around them, so they composite over what's behind). Objects in `skip` +/// stay as they are. +pub(super) fn rasterize_effects( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + skip: &std::collections::HashMap, +) -> (Option, usize) { + rasterize_where(d, cache, 300.0, true, |it| raster_effects(it) && !threaded(d, it) && !skip.contains_key(&it.id)) +} + +/// A drop shadow is the object's only soft effect, and the PDF writer draws its shadow itself +/// (a leaf object, not a group or a frame with items pasted into it). +fn shadow_only(it: &designcraft_doc::Item) -> bool { + let e = &it.effects; + e.drop_shadow.on + && !(e.feather > 0.0 + || e.inner_shadow.on + || e.outer_glow.on + || e.inner_glow.on + || e.bevel.on + || e.satin.on + || e.directional_feather.on + || e.gradient_feather.on) + && it.children().is_empty() + && !it.has_nested_items() +} + +/// For PDF/X-1a: the coverage of each spread object's soft drop shadow at `ppi`, so the PDF draws +/// the shadow in its own swatch's inks through a mask (which craft-prepress flattens per ink) +/// instead of an RGB image of the object. Objects whose only soft effect is the drop shadow. +/// +/// The coverage comes from the object drawn alone, at full opacity and Normal blending, with its +/// shadow and without it ([`shadow_coverage`]): the shadow's own alpha, its opacity included. The +/// object's own opacity and blend mode stay on the object's group in the PDF. +pub(super) fn shadow_masks( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, +) -> std::collections::HashMap { + use designcraft_doc::SpreadRef; + let mut out = std::collections::HashMap::new(); + let todo: Vec<(usize, designcraft_doc::ItemId)> = d + .spreads + .iter() + .enumerate() + .flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && shadow_only(it)).map(move |it| (si, it.id))) + .collect(); + if todo.is_empty() { + return out; + } + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi.clamp(36.0, 1200.0) / 72.0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let grow = designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0; + let reach = it.bounds().inflate(grow, grow); + let (w, h) = ((reach.width() * k).ceil().max(1.0), (reach.height() * k).ceil().max(1.0)); + if !(w.is_finite() && h.is_finite()) || w * h > 60_000_000.0 { + continue; + } + let (w, h) = (w as u32, h as u32); + let mut alpha = |shadow: bool| { + let mut solo = d.clone(); + let Some(sp) = solo.spreads.get_mut(si) else { return Vec::new() }; + let sp = std::sync::Arc::make_mut(sp); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + if let Some(x) = sp.items.first_mut() { + let x = std::sync::Arc::make_mut(x); + x.opacity = 1.0; + x.blend = Default::default(); + x.effects.drop_shadow.on = shadow; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let placed = [designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::IDENTITY }]; + rr.render(&solo, cache, &placed, (w, h), view, &opts).pixels.as_chunks::<4>().0.iter().map(|px| px[3]).collect::>() + }; + let (with, without) = (alpha(true), alpha(false)); + let n = w as usize * h as usize; + if with.len() != n || without.len() != n { + continue; + } + let coverage: Vec = with.iter().zip(&without).map(|(a, b)| shadow_coverage(*a, *b)).collect(); + out.insert(id, designcraft_pdf::ShadowMask { rect: reach, width: w, height: h, coverage: std::sync::Arc::new(coverage) }); + } + out +} + +/// The shadow's alpha `s` at a pixel where the object alone has alpha `without` and the object +/// over its shadow `with`: `with = a + s(1 − a)` for the object's alpha `a`, so the PDF, which +/// draws the object over the shadow, gets the same pixel back. 0 where the object is opaque. +pub(super) fn shadow_coverage(with: u8, without: u8) -> u8 { + let rest = 255 - u32::from(without); + if rest == 0 { + return 0; + } + let shown = u32::from(with.saturating_sub(without)); + u8::try_from(((shown * 255 + rest / 2) / rest).min(255)).unwrap_or(u8::MAX) +} + +/// A copy of `d` where the spread-level objects `pred` picks are images of their appearance at +/// `ppi` (transparent around them). With `keep_wrap`, each object also stays behind as a +/// nonprinting [`wrap_stand_in`], so text wraps around it as before. +pub(super) fn rasterize_where( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + ppi: f64, + keep_wrap: bool, + pred: impl Fn(&designcraft_doc::Item) -> bool, +) -> (Option, usize) { + use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape, SpreadRef}; + let todo: Vec<(usize, ItemId)> = + d.spreads.iter().enumerate().flat_map(|(si, sp)| sp.items.iter().filter(|it| !it.hidden && pred(it)).map(move |it| (si, it.id))).collect(); + if todo.is_empty() { + return (None, 0); + } + let mut out = d.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let k = ppi / 72.0; + let mut n = 0; + for (si, id) in todo { + let Some(it) = d.item(id) else { continue }; + let reach = it.bounds().inflate( + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + designcraft_render::effect_outset(it) + it.stroke.extent() + 2.0, + ); + // Just this object on its spread, no paper. + let mut solo = d.clone(); + let sp = std::sync::Arc::make_mut(&mut solo.spreads[si]); + sp.items.retain(|x| x.id == id); + for pg in &mut sp.pages { + pg.show_parent_items = false; + } + let (w, h) = ((reach.width() * k).ceil().max(1.0) as u32, (reach.height() * k).ceil().max(1.0) as u32); + if w as u64 * h as u64 > 60_000_000 { + continue; + } + let view = designcraft_geom::Affine::scale(k) * designcraft_geom::Affine::translate((-reach.x0, -reach.y0)); + let opts = designcraft_render::RenderOptions { paper: false, background: None, printing_only: true, ..Default::default() }; + let img = rr.render( + &solo, + cache, + &[designcraft_render::Placed { spread: SpreadRef::Doc(si), xf: designcraft_geom::Affine::translate(designcraft_geom::Vec2::ZERO) }], + (w, h), + view, + &opts, + ); + let aid = AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(Asset { + id: aid, + name: format!("effects-{}.png", id.0), + mime: "image/png".into(), + data: std::sync::Arc::new(img.to_png()), + pixels: Some((w, h)), + ..Default::default() + }), + ); + let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach)); + img_item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (w as f64, h as f64), + xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k), + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + img_item.stroke.weight = 0.0; + img_item.alt_text = it.alt_text.clone(); + img_item.export_options = it.export_options.clone(); + // Same place in the stacking order. + let osp = std::sync::Arc::make_mut(&mut out.spreads[si]); + if let Some(pos) = osp.items.iter().position(|x| x.id == id) { + osp.items[pos] = std::sync::Arc::new(img_item); + if keep_wrap && has_wrap(it, 0) { + osp.items.insert(pos, std::sync::Arc::new(wrap_stand_in(it, 0))); + } + n += 1; + } + } + (Some(out), n) +} + +/// Whether `it`, its placed graphic or an item in it wraps text. +fn has_wrap(it: &designcraft_doc::Item, depth: usize) -> bool { + use designcraft_doc::{Content, WrapMode}; + if depth > MAX_STAND_IN_DEPTH { + return false; + } + it.wrap.mode != WrapMode::None + || match &it.content { + Content::Graphic(g) => g.wrap.mode != WrapMode::None, + Content::Group { items } => items.iter().any(|c| has_wrap(c, depth + 1)), + _ => false, + } +} + +/// Nesting depth past which groups inside a rasterised object keep no wrap stand-ins. +const MAX_STAND_IN_DEPTH: usize = 64; + +/// A nonprinting copy of `it` that keeps its text wrap (and its graphic's and its items') and +/// nothing else that reaches the PDF: no text, form field, button or media. The image of the +/// object stands in for all of that, as it does without the copy. +fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::Item { + use designcraft_doc::Content; + let mut c = it.clone(); + c.nonprinting = true; + c.form_field = None; + c.button = None; + c.media = None; + c.content = match &it.content { + Content::Text(_) => Content::Unassigned, + Content::Group { items } if depth < MAX_STAND_IN_DEPTH => { + Content::Group { items: items.iter().map(|i| std::sync::Arc::new(wrap_stand_in(i, depth + 1))).collect() } + } + Content::Group { .. } => Content::Group { items: Vec::new() }, + other => other.clone(), + }; + c +} + +/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images. +/// +/// Parent-page and anchored placements count too, including anchors inside table-cell, +/// footnote and endnote text. Only the export copy is rewritten. +pub(super) fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option, usize)> { + let choices: Vec<_> = d + .placed_items() + .into_iter() + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let g = it.graphic()?; + let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone())) + }) + .collect(); + if choices.is_empty() { + return Ok((None, 0)); + } + let mut out = d.clone(); + let mut images = std::collections::HashMap::new(); + let mut n = 0; + for (id, g, a, hidden) in choices { + // The whole page at 300 ppi (the graphic may show only a box of it). + let shown = designcraft_render::shown_box(&a); + let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); + let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t)); + let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32; + let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown) + .map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?; + let px = designcraft_render::image_size(&png); + let aid = designcraft_doc::AssetId(out.alloc()); + out.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + id: aid, + name: format!("{}.png", a.name), + mime: "image/png".into(), + data: std::sync::Arc::new(png), + pixels: px, + ..Default::default() + }), + ); + images.insert(id, aid); + n += 1; + } + out.update_placed_items( + |it| images.contains_key(&it.id), + |it| { + if let designcraft_doc::Content::Graphic(gg) = &mut it.content + && let Some(aid) = images.get(&it.id) + { + gg.asset = *aid; + } + }, + ); + Ok((Some(out), n)) +} + +#[cfg(test)] +mod placed_layer_tests { + use std::sync::Arc; + + use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table}; + use designcraft_geom::Rect; + use serde_json::json; + + use crate::Session; + + fn two_layer_pdf() -> Vec { + let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; + let objs = [ + "<>>>>>" + .to_string(), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".to_string(), + "<>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes()); + } + let x = out.len(); + let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); + for o in offs { + t.push_str(&format!("{o:010} 00000 n\r\n")); + } + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + out.extend_from_slice(t.as_bytes()); + out + } + + fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item { + match location { + "cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item, + "footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item, + "endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item, + "anchor" => &d.story(sid).unwrap().objects[0].item, + _ => d.item(id).unwrap(), + } + } + + #[test] + fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let result = s + .execute( + "file.place", + &json!({ + "base64": crate::cmd::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", + }), + ) + .unwrap(); + let id = ItemId(result["id"].as_u64().unwrap()); + s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap(); + // Relinking to an ordinary image can leave legacy fields on the placement. + // They are not an actionable placed-PDF override and must not fail closed. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let aid = d.item(id).unwrap().graphic().unwrap().asset; + let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap()); + asset.mime = "image/png".into(); + asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png()); + let (copy, count) = super::rasterize_layered(d).unwrap(); + assert_eq!(count, 0); + assert!(copy.is_none()); + s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + } + + fn check_placement(location: &str) { + for all_on in [false, true] { + check_choice(location, all_on); + } + } + + fn check_choice(location: &str, all_on: bool) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let placed = s + .execute( + "file.place", + &json!({ + "base64": crate::cmd::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf", + "x": 0, "y": 0, "width": 100, + "spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0} + }), + ) + .unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + let hidden = if all_on { vec![] } else { vec!["Blue"] }; + s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap(); + let sid = { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let layer = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); + if !matches!(location, "doc" | "parent") { + let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone(); + Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id); + let object = AnchoredObject { item, position: Default::default() }; + let story = d.story_mut(sid).unwrap(); + match location { + "cell" => { + let mut table = Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].text.insert_object(0, object); + story.insert_table(0, table); + } + "footnote" => { + story.insert_note(0, "Note", Default::default()).unwrap(); + Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object); + } + "endnote" => { + story.insert_endnote(0, "Endnote", Default::default()).unwrap(); + Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object); + } + "anchor" => story.insert_object(0, object), + _ => assert!(matches!(location, "doc" | "parent")), + } + } + sid + }; + // Exercise the real export command for each location, not just the traversal helper. + let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!( + result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")), + "{location}: {}", + result["warnings"] + ); + // Check the rewritten placement, including its pixels. Counting a placement without + // updating the nested object would still export the file's default layer visibility. + let source = &s.doc().unwrap().doc; + let (copy, count) = super::rasterize_layered(source).unwrap(); + assert_eq!(count, 1, "{location}"); + let copy = copy.unwrap(); + let item = placement(©, location, id, sid); + let asset = ©.assets[&item.graphic().unwrap().asset]; + assert_eq!(asset.mime, "image/png", "{location}"); + let image = designcraft_render::images::decoded(&asset.data, 0); + let image = image.unwrap(); + let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4]; + assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}"); + // Export preparation must not change the open document's placement or source asset. + let original = placement(source, location, id, sid); + assert_eq!(original.pdf_hidden_layers, hidden); + assert!(original.pdf_layer_override); + assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf"); + } + + #[test] + fn layered_pdf_a_document_placement() { + check_placement("doc"); + } + #[test] + fn layered_pdf_a_parent_placement() { + check_placement("parent"); + } + #[test] + fn layered_pdf_a_story_anchor() { + check_placement("anchor"); + } + #[test] + fn layered_pdf_a_table_cell_anchor() { + check_placement("cell"); + } + #[test] + fn layered_pdf_a_footnote_anchor() { + check_placement("footnote"); + } + #[test] + fn layered_pdf_a_endnote_anchor() { + check_placement("endnote"); + } +} diff --git a/crates/engine/src/cmd/export/formats.rs b/crates/engine/src/cmd/export/formats.rs new file mode 100644 index 000000000..31885bee2 --- /dev/null +++ b/crates/engine/src/cmd/export/formats.rs @@ -0,0 +1,413 @@ +use super::flatten::{rasterize_where, threaded}; +use super::pdf::pages_param; +use super::{Arc, Cow, EngineError, Result, Session, Value, bad, json, str_param}; + +pub(super) fn print_booklet(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.printBooklet"; + let kind = match str_param(p, "type").unwrap_or("saddleStitch") { + "saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch, + "twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive, + t => { + return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))); + } + }; + let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None }; + let st = s.doc()?; + let r = designcraft_pdf::export_booklet(&crate::cmd::interchange::with_pdf_boxes(&st.doc), &s.cache, &opts) + .map_err(|e| EngineError::Other(e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "sheets": r.pages, "warnings": r.warnings})), + } +} + +/// HTML and reflowable EPUB embed the EPS preview; keep the session's source bytes intact. +fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Document>, Vec) { + // Match the exporter's reading order (including Article exclusions), not every asset + // in the document: unused or hidden graphics must not prevent an export. + let mut used = std::collections::BTreeSet::new(); + for block in designcraft_epub::reading_order(d) { + let designcraft_epub::Block::Image(id) = block else { + continue; + }; + let Some(it) = d.item(id) else { continue }; + let designcraft_doc::Content::Graphic(g) = &it.content else { + continue; + }; + used.insert(g.asset); + if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some()) + && let Some(poster) = it.media.as_ref().and_then(|m| m.poster) + { + used.insert(poster); + } + } + let mut out = Cow::Borrowed(d); + let mut warnings = Vec::new(); + for id in used { + let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { + continue; + }; + let png = designcraft_images::eps_png(&a.data); + if png.is_none() { + warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name)); + } + if let Some(a) = out.to_mut().assets.get_mut(&id) { + let a = Arc::make_mut(a); + a.mime = "image/png".into(); + a.data = Arc::new(png.unwrap_or_default()); + } + } + (out, warnings) +} + +pub(super) fn export_html(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = designcraft_epub::HtmlOptions { + title: str_param(p, "title").map(str::to_string), + language: str_param(p, "language").map(str::to_string), + generic_families: generic_families(&st.doc), + }; + let (doc, warnings) = eps_previews(&st.doc); + let text = designcraft_epub::export_html(&doc, &opts); + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": text.len(), "warnings": warnings})) + } + None => Ok(json!({"bytes": text.len(), "text": text, "warnings": warnings})), + } +} + +pub(super) fn export_pptx(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportPptx"; + let st = s.doc()?; + let line_breaks = match str_param(p, "lineBreaks") { + None | Some("keep") => designcraft_pptx::LineBreaks::Keep, + Some("reflow") => designcraft_pptx::LineBreaks::Reflow, + Some(x) => return Err(bad(ID, format!("unknown lineBreaks `{x}` (keep, reflow)"))), + }; + let opts = designcraft_pptx::PptxOptions { + pages: pages_param(ID, p, st.doc.page_count())?, + line_breaks, + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + created: None, + embed_fonts: p.get("embedFonts").and_then(Value::as_bool).unwrap_or(true), + }; + // Object Export Options › Rasterize, and what PowerPoint has no objects for (a threaded frame + // stays text: a picture of it would drop the rest of its story). + let auto = p.get("rasterize").and_then(Value::as_bool).unwrap_or(true); + let (raster, rasterized) = rasterize_where(&st.doc, &s.cache, 300.0, true, |it| { + !threaded(&st.doc, it) && (it.export_options.rasterize || (auto && designcraft_pptx::needs_raster(&st.doc, it))) + }); + let doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let mut r = designcraft_pptx::export_pptx(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; + if rasterized > 0 { + r.warnings.push(format!("{rasterized} object(s) went in as images")); + } + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), + } +} + +pub(super) fn has_story_target(s: &Session) -> std::result::Result<(), String> { + crate::cmd::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) +} + +pub(super) fn export_text(s: &mut Session, p: &Value) -> Result { + const ID: &str = "file.exportText"; + let sid = crate::cmd::text::story_of(s, p).ok_or_else(|| bad(ID, "edit text or select a text frame"))?; + let doc = &s.doc()?.doc; + let story = doc.stories.get(&sid).ok_or_else(|| bad(ID, format!("no story {}", sid.0)))?; + let path = str_param(p, "path"); + let format = match str_param(p, "format") { + Some(f @ ("rtf" | "tagged")) => f, + Some("txt") | Some("text") => "txt", + Some(f) => return Err(bad(ID, format!("unknown format `{f}` (txt, rtf, tagged)"))), + None if path.is_some_and(|x| x.to_ascii_lowercase().ends_with(".rtf")) => "rtf", + None => "txt", + }; + let text = match format { + "rtf" => designcraft_textimport::export::rtf(doc, story), + "tagged" => designcraft_textimport::tagged::tagged_text(doc, story), + _ => designcraft_textimport::export::plain_text(story), + }; + match path { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, text.as_bytes()).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": text.len()})) + } + None => Ok(json!({"bytes": text.len(), "text": text})), + } +} + +/// Text of the frames on page `abs`, in line order (for fixed-layout pages). +fn page_text(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, abs: usize) -> String { + let mut out = String::new(); + for st in d.stories.values() { + let cs = cache.get(d, st.id, None); + for ft in &cs.frames { + if d.page_of_item(ft.frame) != Some(abs) { + continue; + } + for l in &ft.lines { + let r = l.range.start.min(st.text.len())..l.range.end.min(st.text.len()); + let t: String = st.text[r].chars().filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)).collect(); + if !t.trim().is_empty() { + out.push_str(t.trim_end_matches('\n')); + out.push('\n'); + } + } + } + } + out +} + +/// The CSS generic family of each font family the EPUB/HTML stylesheet names, from the fonts' +/// classification (PANOSE, OS/2 class, name), for the `font-family` fallbacks. +fn generic_families(d: &designcraft_doc::Document) -> std::collections::BTreeMap { + use designcraft_fonts::FontClass; + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = std::collections::BTreeMap::new(); + for (family, _) in designcraft_epub::stylesheet_fonts(d) { + if out.contains_key(&family) { + continue; + } + let generic = match db.family_kind(&family).map(|(k, _)| k.class) { + Some(FontClass::Serif | FontClass::SlabSerif) => "serif", + Some(FontClass::SansSerif) => "sans-serif", + Some(FontClass::Monospaced) => "monospace", + Some(FontClass::Script) => "cursive", + Some(FontClass::Decorative) => "fantasy", + Some(FontClass::Symbol | FontClass::Other) | None => continue, + }; + out.insert(family, generic.to_string()); + } + out +} + +/// The faces the EPUB stylesheet names, to package with it; `warnings` gets the ones left out +/// (not available, a licence that forbids embedding, or part of a font collection). +fn embedded_fonts(d: &designcraft_doc::Document, warnings: &mut Vec) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut out = Vec::new(); + for (family, style) in designcraft_epub::stylesheet_fonts(d) { + let face = db.face(&family, &style); + if !face.family.eq_ignore_ascii_case(&family) { + warnings.push(format!("font `{family} {style}` is not available; not embedded")); + } else if face.restricted_licence() || face.bitmap_only() { + warnings.push(format!("font `{family} {style}`: its licence doesn't allow embedding; not embedded")); + } else if face.data().starts_with(b"ttcf") { + warnings.push(format!("font `{family} {style}` is part of a font collection; not embedded")); + } else { + out.push(designcraft_epub::EmbeddedFont { family, style: face.style.clone(), data: face.data().to_vec() }); + } + } + out +} + +/// A Split Document setting (`"off"`, `"exportTags"` or `{"style": name}`), naming a paragraph +/// style the document has. +pub(crate) fn epub_split_param(id: &str, doc: &designcraft_doc::Document, v: &Value) -> Result { + let split: designcraft_doc::EpubSplit = + serde_json::from_value(v.clone()).map_err(|_| bad(id, "`split`: \"off\", \"exportTags\" or {\"style\": name}"))?; + if let designcraft_doc::EpubSplit::Style(name) = &split + && doc.styles.para(name).is_none() + { + return Err(bad(id, format!("no paragraph style `{name}`"))); + } + Ok(split) +} + +pub(super) fn export_epub(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let split = p.get("split").map(|v| epub_split_param("file.exportEpub", &st.doc, v)).transpose()?; + // Object Export Options › Rasterize: those objects go in as images. + let (raster, _) = rasterize_where(&st.doc, &s.cache, 144.0, false, |it| it.export_options.rasterize && !threaded(&st.doc, it)); + let epub_doc: &designcraft_doc::Document = raster.as_ref().unwrap_or(&st.doc); + let fixed = p.get("fixedLayout").and_then(Value::as_bool).unwrap_or(false); + let want_cover = p.get("cover").and_then(Value::as_bool).unwrap_or(false); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = designcraft_render::default_threads(); + let ropts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let mut warnings: Vec = Vec::new(); + // A cover image file (PNG or JPEG), else the first page rendered when `cover` asks for it. + let cover = match p.get("coverImage").and_then(Value::as_str).filter(|_| !fixed) { + Some(path) => { + Some(crate::cmd::datamerge::read_path_capped(path, crate::cmd::datamerge::MAX_IMAGE_FILE_BYTES).map_err(crate::EngineError::Other)?) + } + None => (want_cover && !fixed).then(|| rr.render_page(&st.doc, &s.cache, 0, 2.0, false, &ropts).map(|img| img.to_png())).flatten(), + }; + if let Some(c) = &cover + && !(c.starts_with(b"\x89PNG") || c.starts_with(&[0xFF, 0xD8, 0xFF])) + { + return Err(crate::EngineError::Other("the cover image must be a PNG or JPEG file".into())); + } + let opts = designcraft_epub::EpubOptions { + title: p.get("title").and_then(Value::as_str).map(str::to_string), + author: p.get("author").and_then(Value::as_str).map(str::to_string), + language: p.get("language").and_then(Value::as_str).unwrap_or("en").to_string(), + identifier: p.get("identifier").and_then(Value::as_str).map(str::to_string), + cover, + toc_title: p.get("tocTitle").and_then(Value::as_str).map(str::to_string), + split, + generic_families: generic_families(&st.doc), + fonts: if p.get("embedFonts").and_then(Value::as_bool).unwrap_or(false) && !fixed { + embedded_fonts(&st.doc, &mut warnings) + } else { + Vec::new() + }, + // The export time; on the web the document's own save time stands in. + #[cfg(not(target_arch = "wasm32"))] + modified: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()), + #[cfg(target_arch = "wasm32")] + modified: None, + }; + if fixed { + let d = &st.doc; + let mut pages = Vec::new(); + for i in 0..d.page_count() { + let Some((si, pi)) = d.page_loc(i) else { + continue; + }; + let b = d.spreads[si].pages[pi].bounds(); + let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { + continue; + }; + pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) }); + } + let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + return match p.get("path").and_then(Value::as_str) { + Some(path) => { + write_file(path, &bytes)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "pages": pages.len()})) + } + None => Ok(serde_json::json!({"base64": crate::cmd::base64_encode(&bytes), "bytes": bytes.len(), "pages": pages.len()})), + }; + } + let (epub_doc, eps_warnings) = eps_previews(epub_doc); + warnings.extend(eps_warnings); + let bytes = designcraft_epub::export_epub(&epub_doc, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?; + warnings.extend( + designcraft_epub::images_without_alt(&epub_doc) + .into_iter() + .map(|name| format!("{name}: no alt text (Object Export Options); exported as decorative (alt=\"\")")), + ); + match p.get("path").and_then(Value::as_str) { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &bytes).map_err(crate::EngineError::Other)?; + Ok(serde_json::json!({"path": path, "bytes": bytes.len(), "warnings": warnings})) + } + None => Ok(serde_json::json!({"base64": crate::cmd::file::base64_encode(&bytes), "bytes": bytes.len(), "warnings": warnings})), + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + crate::io::write_file(path, bytes).map_err(EngineError::Other) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes"))) +} + +#[cfg(test)] +mod booklet_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn booklet_imposes_pages_in_pairs() { + use designcraft_pdf::{BookletKind, booklet_pairs}; + assert_eq!(booklet_pairs(8, BookletKind::SaddleStitch), [(Some(7), Some(0)), (Some(1), Some(6)), (Some(5), Some(2)), (Some(3), Some(4))]); + assert_eq!(booklet_pairs(6, BookletKind::SaddleStitch)[0], (None, Some(0)), "padded with blanks"); + assert_eq!(booklet_pairs(3, BookletKind::TwoUpConsecutive), [(Some(0), Some(1)), (Some(2), None)]); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let r = s.execute("file.printBooklet", &json!({"spaceBetween": 18})).unwrap(); + assert_eq!(r["sheets"], 2); + let pdf = String::from_utf8_lossy(&crate::cmd::file::base64_decode(r["base64"].as_str().unwrap())).into_owned(); + let mb = pdf.find("/MediaBox").map(|i| pdf[i..i + 40].to_string()).unwrap_or_default(); + assert!(mb.contains("1242"), "two 612 pt pages and an 18 pt gap: {mb}"); + assert!(s.execute("file.printBooklet", &json!({"type": "perfectBound"})).is_err()); + // Page 1's black box lands on the right half of the first sheet (page 4 is on the left). + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); + let r = s.execute("file.printBooklet", &json!({})).unwrap(); + let bytes = crate::cmd::file::base64_decode(r["base64"].as_str().unwrap()); + let px = designcraft_render::decode_pixmap_page(&bytes, 0).expect("rasterised"); + let (w, h) = (px.width() as f64, px.height() as f64); + let at = |x: f64, y: f64| px.sample((x / 1224.0 * w) as u16, (y / 792.0 * h) as u16); + let (ink, blank) = (at(612.0 + 200.0, 200.0), at(200.0, 200.0)); + assert!(ink.a > 200 && ink.r < 60, "right half has page 1: {ink:?}"); + assert!(blank.a < 30 || blank.r > 200, "left half (page 4) is empty: {blank:?}"); + } +} + +#[cfg(test)] +mod pptx_tests { + use serde_json::json; + + use crate::Session; + + fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(crate::cmd::file::base64_decode(b64))).unwrap() + } + + fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s + } + + #[test] + fn sample_exports_editable_slides() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + assert_eq!(r["slides"], 4); + let mut z = unzip(r["base64"].as_str().unwrap()); + // The swatch table is a PowerPoint table; the cover picture is embedded; text is text. + assert!(read(&mut z, "ppt/slides/slide4.xml").contains("")); + assert!(read(&mut z, "ppt/slides/slide1.xml").contains("")); + let text: String = read(&mut z, "ppt/slides/slide2.xml").split("").skip(1).filter_map(|t| t.split("").next()).collect(); + assert!(text.contains("Notes on the Grid"), "{text}"); + assert!(read(&mut z, "ppt/presentation.xml").contains("")); + + let r = s.execute("file.exportPptx", &json!({"pages": "2-3", "embedFonts": false, "lineBreaks": "reflow"})).unwrap(); + assert_eq!(r["slides"], 2); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fonts are not embedded"))); + assert!(s.execute("file.exportPptx", &json!({"lineBreaks": "wrap"})).is_err()); + assert!(s.execute("file.exportPptx", &json!({"pages": [9]})).is_err()); + } + + #[test] + fn rasterize_option_places_a_picture() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + let shapes = |s: &mut Session| { + let r = s.execute("file.exportPptx", &json!({})).unwrap(); + read(&mut unzip(r["base64"].as_str().unwrap()), "ppt/slides/slide1.xml") + }; + assert!(shapes(&mut s).contains("prst=\"ellipse\"")); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true})).unwrap(); + let x = shapes(&mut s); + assert!(x.contains("") && !x.contains("prst=\"ellipse\""), "{x}"); + } +} diff --git a/crates/engine/src/cmd/export/pdf.rs b/crates/engine/src/cmd/export/pdf.rs new file mode 100644 index 000000000..903639ff9 --- /dev/null +++ b/crates/engine/src/cmd/export/pdf.rs @@ -0,0 +1,463 @@ +use super::flatten::{flatten, rasterize_effects, rasterize_layered, shadow_masks, transparent}; +use super::formats::write_file; +use super::{EngineError, Marks, PdfOptions, Result, Session, Standard, Value, bad, json, str_param}; + +/// The `pages` parameter: a range string ("1-3,5"), a list or a number of 1-based positions. +pub(super) fn pages_param(id: &str, p: &Value, page_count: usize) -> Result>> { + Ok(match p.get("pages") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(id, e.to_string()))?), + Some(Value::Array(a)) => { + let mut v = Vec::new(); + for x in a { + let n = x + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {x}")))?; + // A page named twice is one page, as `"2,2"` already is: the list says which + // pages to export, not how many times. `[2, 2]` exported two copies of page 2 + // while `"2,2"` exported one. + if !v.contains(&(n - 1)) { + v.push(n - 1); + } + } + Some(v) + } + Some(Value::Number(n)) => { + let n = n + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| *n >= 1 && *n <= page_count) + .ok_or_else(|| bad(id, format!("bad page {n}")))?; + Some(vec![n - 1]) + } + Some(v) => return Err(bad(id, format!("bad `pages`: {v}"))), + }) +} + +pub(crate) fn options(p: &Value, page_count: usize) -> Result { + const ID: &str = "file.exportPdf"; + + let pages = pages_param(ID, p, page_count)?; + let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false); + let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); + let marks = match p.get("marks") { + Some(Value::Bool(true)) => Marks::ALL, + Some(Value::Object(m)) => { + let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false); + Marks { + crop: b("crop"), + bleed: b("bleed"), + page_info: b("pageInfo"), + weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25), + offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0), + } + } + _ => Marks::NONE, + }; + let standard = match str_param(p, "standard") { + Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, x1a, a2b)")))?, + None => Standard::None, + }; + let created = match p.get("created") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_i64().ok_or_else(|| bad(ID, "`created`: Unix seconds, an integer"))?), + }; + Ok(PdfOptions { + pages, + spreads, + bleed, + marks, + standard, + compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), + title: str_param(p, "title").map(str::to_string), + author: str_param(p, "author").map(str::to_string), + tagged: p.get("tagged").and_then(Value::as_bool).unwrap_or(false), + media: p.get("media").and_then(Value::as_bool).unwrap_or(false), + created, + ..PdfOptions::default() + }) +} + +/// Object number of the first page of a PDF (its page tree's first kid). +fn first_page_ref(pdf: &[u8]) -> Option { + let s = String::from_utf8_lossy(pdf); + let i = s.find("/Kids[")? + 6; + s[i..].split_whitespace().next()?.parse().ok() +} + +pub(super) fn export_pdf(s: &mut Session, p: &Value) -> Result { + let r = pdf_export(s, p)?; + match str_param(p, "path") { + Some(path) => { + write_file(path, &r.bytes)?; + Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})) + } + None => Ok(json!({"base64": crate::cmd::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})), + } +} + +/// Transparency Flattener presets (`flatten`): High / Medium / Low Resolution, or a ppi. +pub(crate) fn flatten_ppi(id: &str, p: &Value) -> Result> { + Ok(match p.get("flatten") { + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::String(s)) if s == "high" => Some(300.0), + Some(Value::String(s)) if s == "medium" => Some(150.0), + Some(Value::String(s)) if s == "low" => Some(72.0), + Some(Value::Bool(true)) => Some(300.0), + Some(v) => Some(v.as_f64().filter(|v| (36.0..=1200.0).contains(v)).ok_or_else(|| bad(id, "`flatten`: high|medium|low or 36–1200 ppi"))?), + }) +} + +/// The print PDF of `opts`' pages: the transparency flattener at `ppi` (if any), placed PDFs +/// with hidden layers and soft effects as images, the PDF boxes of placed graphics, the PDF +/// itself and its transparency blend space. Export PDF and File › Print add their view options +/// to it ([`pdf_export`]); Separations Preview separates it as it is. +pub(crate) fn print_pdf( + d: &designcraft_doc::Document, + cache: &designcraft_compose::Cache, + opts: &PdfOptions, + ppi: Option, +) -> Result { + // PDF/X-1a: craft-prepress flattens the PDF in its inks; `ppi` sets its resolution. + let x1a = opts.standard == Standard::PdfX1a; + let mut opts = opts.clone(); + if x1a { + opts.flatten_dpi = ppi.unwrap_or(300.0) as f32; + } + let flattened = if x1a { None } else { ppi.map(|ppi| flatten(d, cache, ppi)) }; + let base: &designcraft_doc::Document = flattened.as_ref().map_or(d, |f| &f.0); + let (layered, layer_count) = if opts.standard == Standard::PdfA2b { rasterize_layered(base)? } else { (None, 0) }; + let base2: &designcraft_doc::Document = layered.as_ref().unwrap_or(base); + let shadows = if x1a { shadow_masks(base2, cache, f64::from(opts.flatten_dpi)) } else { Default::default() }; + let (fx_doc, fx_count) = rasterize_effects(base2, cache, &shadows); + opts.shadows = std::sync::Arc::new(shadows); + let opts = &opts; + let doc: &designcraft_doc::Document = fx_doc.as_ref().unwrap_or(base2); + let boxed = crate::cmd::interchange::with_pdf_boxes(doc); + let doc: &designcraft_doc::Document = &boxed; + let mut r = designcraft_pdf::export_pdf_with_report(doc, cache, opts).map_err(|e| EngineError::Other(e.to_string()))?; + // Flattening and effect rasterization replace placements, so inspect the source too. + if d.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) { + r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into()); + } + // Transparency Blend Space: the page group of pages with transparency. CMYK print output + // gets its CMYK page groups from the writer. + let spreads = designcraft_pdf::sheet_spreads(doc, opts); + let blended: Vec = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect(); + if !designcraft_pdf::print_color(doc, opts) && blended.iter().any(|b| *b) { + match designcraft_pdf::add_blend_space(&r.bytes, &blended, doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk) { + Some(b) => r.bytes = b, + None => r.warnings.push("the transparency blend space couldn't be set".into()), + } + } + if fx_count > 0 { + r.warnings.push(format!("{fx_count} object(s) with soft effects (shadows, glows, feathers, bevels) exported as images")); + } + if layer_count > 0 { + r.warnings.push(format!("{layer_count} placed PDF(s) with hidden layers exported as images")); + } + if let Some((_, n)) = &flattened + && *n > 0 + { + r.warnings.push(format!("transparency flattened: {n} region(s) rasterised")); + } + Ok(r) +} + +/// The PDF that File › Export PDF writes for `p`, with its warnings: the print PDF +/// ([`print_pdf`]) with its view options, timings and transitions. File › Print prints it. +pub(crate) fn pdf_export(s: &Session, p: &Value) -> Result { + let st = s.doc()?; + let opts = options(p, st.doc.page_count())?; + let ppi = flatten_ppi("file.exportPdf", p)?; + let mut r = print_pdf(&st.doc, &s.cache, &opts, ppi)?; + let doc: &designcraft_doc::Document = &st.doc; + let spreads = designcraft_pdf::sheet_spreads(doc, &opts); + // Interactive PDF view options. + let mut cat = String::new(); + if p.get("fullScreen").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/FullScreen"); + } else if p.get("bookmarksPanel").and_then(Value::as_bool) == Some(true) { + cat.push_str("/PageMode/UseOutlines"); + } + match str_param(p, "pageLayout") { + Some("single") => cat.push_str("/PageLayout/SinglePage"), + Some("continuous") => cat.push_str("/PageLayout/OneColumn"), + Some("twoUp") => cat.push_str("/PageLayout/TwoPageLeft"), + Some("twoUpCover") => cat.push_str("/PageLayout/TwoPageRight"), + Some("twoUpContinuous") => cat.push_str("/PageLayout/TwoColumnRight"), + Some(o) => return Err(bad("file.exportPdf", format!("unknown pageLayout `{o}`"))), + None => {} + } + if let Some(v) = str_param(p, "view") { + // The first page opens with this view (pages are objects in order, so refer by index 0). + let dest = match v { + "fitPage" => "/Fit", + "fitWidth" => "/FitH null", + "actual" => "/XYZ null null 1", + o => { + return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))); + } + }; + if let Some(first) = first_page_ref(&r.bytes) { + cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]")); + } + } + if !cat.is_empty() { + match designcraft_pdf::add_catalog_entries(&r.bytes, &cat) { + Some(b) => r.bytes = b, + None => r.warnings.push("the PDF view options couldn't be set".into()), + } + } + // Flip pages every N seconds (presentation). + if let Some(secs) = p.get("advanceSeconds").and_then(Value::as_f64).filter(|s| *s > 0.0) { + let n = designcraft_pdf::sheet_spreads(doc, &opts).len(); + let dur: Vec> = (0..n).map(|_| Some(format!("/Dur {secs:.2}"))).collect(); + match designcraft_pdf::add_page_entries(&r.bytes, &dur) { + Some(b) => r.bytes = b, + None => r.warnings.push("page timings couldn't be set".into()), + } + } + // Page transitions (each spread's first page holds them). + let trans: Vec> = + spreads.into_iter().map(|si| doc.spreads.get(si).and_then(|sp| sp.pages.first()).and_then(|pg| pg.transition)).collect(); + if trans.iter().any(Option::is_some) { + match designcraft_pdf::add_transitions(&r.bytes, &trans) { + Some(b) => r.bytes = b, + None => r.warnings.push("page transitions couldn't be added to this PDF".into()), + } + } + Ok(r) +} + +#[cfg(test)] +mod tagged_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tagged_pdf_has_structure_and_alt_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 320, 200, 420]})).unwrap(); + s.execute("object.altText", &json!({"text": "A plain box", "ids": [r["id"]]})).unwrap(); + let pdf = |s: &mut Session, tagged: bool| { + let b64 = s.execute("file.exportPdf", &json!({"tagged": tagged})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&crate::cmd::file::base64_decode(&b64)).into_owned() + }; + let t = pdf(&mut s, true); + assert!(t.contains("/StructTreeRoot") && t.contains("/Figure") && t.contains("A plain box"), "tagged"); + assert!(t.contains("/P") && t.contains("/MarkInfo")); + assert!(!pdf(&mut s, false).contains("/StructTreeRoot")); + } +} + +#[cfg(test)] +mod variable_font_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn variable_font_instance_exports_to_pdf() { + let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { + return; + }; + designcraft_fonts::FontDb::global().add_font(data); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Variable"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Skia", "fontStyle": "Black"})).unwrap(); + let out = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(out["bytes"].as_u64().unwrap() > 1000); + let fonts = s.execute("font.list", &json!({})).unwrap(); + assert!(fonts.to_string().contains("Black"), "{fonts}"); + } +} + +#[cfg(test)] +mod placed_pdf_print_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF 1.7 failed the PDF/X-4 (PDF 1.6) export with + /// "PDF writer error: Pdf(PdfDocument(PdfDocumentRepr { .. }), VersionMismatch(Pdf17), None)". + #[test] + fn pdf_17_placed_in_pdfx4() { + let logo = logo(); + assert!(logo.starts_with(b"%PDF-1.7")); + let mut s = page_with(&logo); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap(); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + assert!(base64_decode(r["base64"].as_str().unwrap()).starts_with(b"%PDF-1.6")); + // PDF 2.0 can't simply be relabelled: the export fails and names the file. + let mut v20 = logo.clone(); + v20[5..8].copy_from_slice(b"2.0"); + let mut s = page_with(&v20); + let e = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_err().to_string(); + assert!(e.contains("logo.pdf is PDF 2.0") && e.contains("PDF 1.6"), "{e}"); + } + + /// A one-page PDF with a black box whose catalog says `/Version /1.7`, as TCPDF writes it. + fn catalog_v17(header: &str) -> Vec { + let content = "0 0 0 1 k 0 0 200 200 re f"; + let mut pdf = format!("{header}\n").into_bytes(); + let mut offsets = Vec::new(); + for body in [ + "<>".to_owned(), + "<>".to_owned(), + "<>>>".to_owned(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", offsets.len()).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n\r\n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + pdf + } + + /// A placed PDF whose catalog `/Version /1.7` overrides its header failed the PDF/X-4 export + /// with "logo.pdf is PDF 1.7", though a 1.7 header alone is relabelled (#907). + #[test] + fn catalog_version_17_placed_in_pdfx4() { + for header in ["%PDF-1.7", "%PDF-1.4"] { + let pdf = catalog_v17(header); + assert_eq!(hayro_syntax::Pdf::new(pdf.clone()).unwrap().version(), hayro_syntax::PdfVersion::Pdf17); + let mut s = page_with(&pdf); + let r = s.execute("file.exportPdf", &json!({"standard": "x4"})).unwrap_or_else(|e| panic!("{header}: {e}")); + assert!(r["warnings"].to_string().contains("logo.pdf: a PDF 1.7 placed in a PDF 1.6 file"), "{}", r["warnings"]); + let out = base64_decode(r["base64"].as_str().unwrap()); + assert!(out.starts_with(b"%PDF-1.6")); + assert_eq!(hayro_syntax::Pdf::new(out).unwrap().version(), hayro_syntax::PdfVersion::Pdf16); + } + } +} + +#[cfg(test)] +mod print_group_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::{base64_decode, base64_encode}; + + /// A one-page PDF 1.7 with a black box, as VectorCraft writes a logo. + fn logo() -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + base64_decode(r["base64"].as_str().unwrap()) + } + + fn page_with(pdf: &[u8]) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"base64": base64_encode(pdf), "name": "logo.pdf", "x": 72, "y": 72})).unwrap(); + s + } + + /// A placed PDF's transparency group blends in CMYK in a print document and in RGB in a + /// screen one. + #[test] + fn print_transparency_groups_blend_in_cmyk() { + let mut s = page_with(&logo()); + let text = |s: &mut Session| { + let r = s.execute("file.exportPdf", &json!({"compressImages": false})).unwrap(); + String::from_utf8_lossy(&base64_decode(r["base64"].as_str().unwrap())).into_owned() + }; + let t = text(&mut s); + // The placed page's group inherits the page group, which blends in CMYK. + assert!(t.contains("/Group<>"), "placed page group without a blending space"); + assert!(t.contains("/Group<>"), "CMYK page group"); + assert!(!t.contains("DeviceRGB"), "no RGB left"); + assert!(!designcraft_pdf::has_rgb_groups(t.as_bytes())); + // The file still reads, with the same page. + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = base64_decode(r["base64"].as_str().unwrap()); + assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 1); + // An RGB blend space keeps RGB groups. + s.execute("edit.transparencyBlendSpace", &json!({"space": "rgb"})).unwrap(); + let t = text(&mut s); + assert!(t.contains("/CS/DeviceRGB") && designcraft_pdf::has_rgb_groups(t.as_bytes())); + } +} + +#[cfg(test)] +mod page_list_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn doc(pages: u64) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "pages": pages, "facingPages": false, "margins": 0})).unwrap(); + s + } + + /// The same selection written two ways exported different files: `"2,2"` is one page, because + /// `parse_page_range` keeps the first of a repeat, while `[2, 2]` was two copies of page 2. + /// The list says which pages to export, not how many times. + #[test] + fn a_page_named_twice_is_exported_once_either_way() { + let mut s = doc(4); + for (pages, want) in [ + (json!("2,2"), 1), + (json!([2, 2]), 1), + (json!([2, 2, 2]), 1), + (json!("2-2,2"), 1), + (json!([1, 2, 1]), 2), + (json!("1,2,1"), 2), + (json!([4, 1]), 2), + (json!("4,1"), 2), + (json!("all"), 4), + ] { + let r = s.execute("file.exportPdf", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["pages"].as_u64(), Some(want), "pdf {pages}"); + let r = s.execute("file.exportPptx", &json!({"pages": pages.clone()})).unwrap(); + assert_eq!(r["slides"].as_u64(), Some(want), "pptx {pages}"); + } + // The two forms now agree byte for byte, and the pages come out in the document's order + // whichever order the list names them in (which is how the export already behaved). + // A fixed creation time: exports a second apart would differ in their dates. + let mut file = |pages: Value| { + let r = s.execute("file.exportPdf", &json!({"pages": pages, "created": 0})).unwrap(); + r["base64"].as_str().unwrap_or_default().to_string() + }; + assert_eq!(file(json!([4, 1])), file(json!("4,1")), "the array and the string agree"); + assert_eq!(file(json!([4, 1])), file(json!([1, 4])), "pages 1 and 4, in the document's order"); + assert_eq!(file(json!([2, 2])), file(json!("2")), "a repeat is the single page"); + // A page that does not exist is still refused, and an empty list still exports nothing. + assert!(s.execute("file.exportPdf", &json!({"pages": [5]})).is_err()); + assert!(s.execute("file.exportPdf", &json!({"pages": []})).is_err()); + } +} diff --git a/crates/engine/src/cmd/export/registry.rs b/crates/engine/src/cmd/export/registry.rs new file mode 100644 index 000000000..5f7f1d013 --- /dev/null +++ b/crates/engine/src/cmd/export/registry.rs @@ -0,0 +1,53 @@ +use super::formats::{epub_split_param, export_epub, export_html, export_pptx, export_text, has_story_target, print_booklet}; +use super::pdf::export_pdf; +use super::{CommandSpec, Value, cmd, has_doc, json}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), flatten?: high|medium|low|ppi (Transparency Flattener: spreads with transparency are rasterised; with standard x1a, the resolution complex transparent regions are rasterized at: high 300, medium 150, low 72, default 300), spreads?: bool, fullScreen?: bool, bookmarksPanel?: bool, pageLayout?: single|continuous|twoUp|twoUpCover|twoUpContinuous, view?: fitPage|fitWidth|actual, advanceSeconds?: number (interactive PDF), bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"x1a\"|\"a2b\" (x1a: PDF/X-1a:2003, the PDF/X-4 file flattened in its inks by craft-prepress: no transparency, only CMYK, gray and spot color), compressImages?: bool, tagged?: bool (structure tree: stories as paragraphs, figures with alt text), media?: bool (interactive: embed placed video and sound), title?, author?, created?: int (creation date in Unix seconds; default now, for reproducible files)} → {path, bytes, pages, warnings} (no path: {base64, …})", + has_doc, export_pdf), + cmd!(noundo "file.exportEpub", "Export EPUB (Reflowable)…", ["File"], None, + "{path?, title?, author?, language?: \"en\", identifier?: str (dc:identifier; a stable urn:uuid by default), coverImage?: path (a PNG or JPEG as the cover), tocTitle?: str (the table of contents' title; from the language by default), cover?: bool (the first page as the cover image), embedFonts?: bool (package the stylesheet's fonts whose licence allows it, whole files), split?: \"off\"|\"exportTags\"|{style: name} (Split Document for this export, not stored; the document's `document.epubSplit` by default), fixedLayout?: bool (pre-paginated: each page as an image with its text)} → {path, bytes, warnings} (no path: {base64, bytes, warnings}); warnings name images without alt text, exported as decorative, and fonts that could not be embedded", + has_doc, export_epub), + cmd!( + "document.epubSplit", + "EPUB Split Document", + [], + None, + "{split?: \"off\"|\"exportTags\"|{style: name}} — EPUB Export Options › Split Document, stored with the document: one content file (off, the default), a file before each paragraph whose style has Export Tagging › Split Document on (exportTags), or before each paragraph of one style → {split}", + has_doc, + |s, p| { + const ID: &str = "document.epubSplit"; + let cur = s.doc()?.doc.settings.epub_split.clone(); + let Some(v) = p.get("split") else { return Ok(json!({"split": cur})) }; + let split = epub_split_param(ID, &s.doc()?.doc, v)?; + if split != cur { + let stored = split.clone(); + s.edit(move |d, _| { + d.settings.epub_split = stored; + Ok(Value::Null) + })?; + } + Ok(json!({"split": split})) + } + ), + cmd!(noundo "file.exportFixedEpub", "Export EPUB (Fixed Layout)…", ["File"], None, + "{path?, title?, author?, language?} — pre-paginated EPUB → {path, bytes} (no path: {base64, bytes})", + has_doc, |s, p| { + export_epub(s, &crate::cmd::with_param(p, "fixedLayout", serde_json::json!(true))) + }), + cmd!(noundo "file.printBooklet", "Print Booklet…", ["File"], None, + "{path?, type?: saddleStitch|twoUpConsecutive, spaceBetween? (pt)} — printer spreads as PDF (pages imposed in booklet order) → {path, bytes, sheets} (no path: {base64, …})", + has_doc, print_booklet), + cmd!(noundo "file.exportHtml", "Export HTML…", ["File"], None, + "{path?, title?, language?} — one self-contained page (styles inline, images embedded), stories and graphics in reading order → {path, bytes, warnings} (no path: {text, bytes, warnings})", + has_doc, export_html), + cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None, + "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})", + has_doc, export_pptx), + cmd!(noundo "file.exportText", "Export Text…", ["File"], None, + "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})", + has_story_target, export_text), + ] +} diff --git a/crates/engine/src/cmd/export/text_tests.rs b/crates/engine/src/cmd/export/text_tests.rs new file mode 100644 index 000000000..e533320f8 --- /dev/null +++ b/crates/engine/src/cmd/export/text_tests.rs @@ -0,0 +1,378 @@ +use serde_json::json; + +use crate::Session; + +fn unzip(b64: &str) -> zip::ZipArchive>> { + zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(b64))).unwrap() +} + +fn read(z: &mut zip::ZipArchive>>, name: &str) -> String { + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut s).unwrap(); + s +} + +/// Split Document is stored with the document as one undo step, follows a renamed style, and +/// an export can override it without storing its own. +#[test] +fn epub_split_document_setting() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nText\nTwo\nMore"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter"})).unwrap(); + for at in [0, 9] { + s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Chapter"})).unwrap(); + } + let files = |s: &mut Session, p: serde_json::Value| { + let mut z = unzip(s.execute("file.exportEpub", &p).unwrap()["base64"].as_str().unwrap()); + (1..).take_while(|n| z.by_name(&if *n == 1 { "OEBPS/content.xhtml".into() } else { format!("OEBPS/content{n}.xhtml") }).is_ok()).count() + }; + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); + assert_eq!(files(&mut s, json!({})), 1, "one content file by default"); + assert!(s.execute("document.epubSplit", &json!({"split": {"style": "Nope"}})).is_err()); + assert!(s.execute("document.epubSplit", &json!({"split": "h1"})).is_err()); + assert!(s.execute("file.exportEpub", &json!({"split": {"style": "Nope"}})).is_err()); + assert_eq!(s.execute("document.epubSplit", &json!({"split": {"style": "Chapter"}})).unwrap()["split"], json!({"style": "Chapter"})); + assert_eq!(files(&mut s, json!({})), 2); + assert_eq!(files(&mut s, json!({"split": "off"})), 1); + assert_eq!(s.doc().unwrap().doc.settings.epub_split, designcraft_doc::EpubSplit::Style("Chapter".into()), "the override isn't stored"); + s.execute("style.paragraph.edit", &json!({"name": "Chapter", "rename": "Part"})).unwrap(); + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], json!({"style": "Part"})); + assert_eq!(files(&mut s, json!({})), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("document.epubSplit", &json!({})).unwrap()["split"], "off"); +} + +#[test] +fn object_export_options() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // A plain rectangle rasterised for EPUB, centred, after a page break. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200], "shape": "ellipse"})).unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [r["id"]]})).unwrap(); + s.execute("object.exportOptions", &json!({"ids": [r["id"]], "rasterize": true, "align": "center", "pageBreakBefore": true, "altText": "A dot"})) + .unwrap(); + let e = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = unzip(e["base64"].as_str().unwrap()); + let x = read(&mut z, "OEBPS/content.xhtml"); + assert!(x.contains("
")); + assert!(z.by_name("OEBPS/images/cover.png").is_ok()); + let r = s.execute("file.exportFixedEpub", &json!({})).unwrap(); + assert_eq!(r["pages"], 2); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("rendition:layout\">pre-paginated") && opf.contains("page2.xhtml")); + let p1 = read(&mut z, "OEBPS/page1.xhtml"); + assert!(p1.contains("width=612, height=792") && p1.contains("Page one words"), "{p1}"); +} + +/// #775: the fallback follows the font's class, and embedFonts packages the stylesheet's fonts +/// (and reports those it can't). +#[test] +fn epub_font_fallbacks_and_embedding() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sans", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Regular"}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Gone", "chars": {"fontFamily": "No Such Font Family", "fontStyle": "Regular"}})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("font-family: \"Source Sans 3\", sans-serif;"), "{css}"); + assert!(!css.contains("@font-face") && r["warnings"] == json!([]), "nothing embedded unless asked"); + let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(css.contains("@font-face { font-family: \"Source Sans 3\"; src: url(\"fonts/"), "{css}"); + let opf = read(&mut z, "OEBPS/content.opf"); + let font = opf.split("href=\"fonts/").nth(1).and_then(|r| r.split('"').next()).map(|n| format!("OEBPS/fonts/{n}")); + assert!(font.as_ref().is_some_and(|f| z.by_name(f).is_ok_and(|e| e.size() > 1000)), "{opf}"); + let warnings = r["warnings"].as_array().unwrap(); + assert!(warnings.iter().any(|w| w.as_str().unwrap().contains("No Such Font Family")), "{warnings:?}"); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("\"Source Sans 3\", sans-serif;")); +} + +/// A font whose licence allows embedding bitmaps only (fsType bit 9) is not packaged whole, +/// and a Semibold style asks for the Semibold face's weight, not Bold's. +#[test] +fn epub_embedding_follows_bitmap_only_licences_and_weights() { + use designcraft_fonts::testing::{font_with, with_fs_type}; + const BITMAP: &str = "DocFont Epub Bitmap Only"; + for fs_type in [0x0200, 0x0208] { + designcraft_fonts::FontDb::global().add_font(with_fs_type(font_with(&format!("{BITMAP} {fs_type:04x}"), &['W']).unwrap(), fs_type).unwrap()); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + for fs_type in [0x0200, 0x0208] { + let family = format!("{BITMAP} {fs_type:04x}"); + s.execute("style.paragraph.create", &json!({"name": family, "chars": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap(); + } + s.execute("style.paragraph.create", &json!({"name": "Semi", "chars": {"fontFamily": "Source Sans 3", "fontStyle": "Semibold"}})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"embedFonts": true})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let css = read(&mut z, "OEBPS/style.css"); + assert!(!css.contains(&format!("@font-face {{ font-family: \"{BITMAP}")), "{css}"); + let warnings = r["warnings"].to_string(); + for fs_type in [0x0200, 0x0208] { + assert!(warnings.contains(&format!("{BITMAP} {fs_type:04x} Regular`: its licence doesn't allow embedding")), "{warnings}"); + } + let semi = css.lines().find(|l| l.contains(".semi {")).unwrap_or_default(); + assert!(semi.contains("font-weight: 600;"), "{css}"); +} + +/// #771, #773: a cover image file and an identifier through the command; the package's +/// modified time is the export's. +#[test] +fn epub_cover_image_file_and_identifier() { + let dir = std::env::temp_dir().join(format!("dc-epub-cover-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let jpg = dir.join("cover.jpg"); + std::fs::write(&jpg, [0xFF, 0xD8, 0xFF, 0xE0, 0, 16]).unwrap(); + let bad = dir.join("cover.txt"); + std::fs::write(&bad, b"not an image").unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Words", "caret": false})).unwrap(); + let r = s.execute("file.exportEpub", &json!({"coverImage": jpg.to_str().unwrap(), "identifier": "urn:isbn:9780000000002"})).unwrap(); + let mut z = unzip(r["base64"].as_str().unwrap()); + let opf = read(&mut z, "OEBPS/content.opf"); + assert!(opf.contains("urn:isbn:9780000000002"), "{opf}"); + assert!(opf.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\""), "{opf}"); + assert!(!opf.contains("2026-01-01T00:00:00Z"), "the export time, not a fixed date: {opf}"); + assert!(z.by_name("OEBPS/images/cover.jpg").is_ok()); + assert!(s.execute("file.exportEpub", &json!({"coverImage": bad.to_str().unwrap()})).is_err()); + std::fs::remove_dir_all(&dir).ok(); +} + +/// The color spaces of every image XObject in `pdf`, as the families and colorant names. +fn image_spaces(pdf: &[u8]) -> Vec { + use hayro_syntax::object::{Name, Object}; + let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap(); + file.objects() + .into_iter() + .filter_map(|o| o.into_stream()) + .filter(|s| s.dict().get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image")) + .filter_map(|s| match s.dict().get::>(b"ColorSpace")? { + Object::Name(n) => Some(n.as_str().to_string()), + Object::Array(a) => { + let mut it = a.iter::>(); + let family = it.next()?.into_name()?.as_str().to_string(); + let names = match it.next() { + Some(Object::Array(n)) => n.iter::>().map(|n| n.as_str().to_string()).collect::>().join(" "), + Some(Object::Name(n)) => n.as_str().to_string(), + _ => String::new(), + }; + Some(format!("{family} {names}")) + } + _ => None, + }) + .collect() +} + +/// The (Black, Gold) samples of every flattened DeviceN image that has both colorants. +fn black_and_gold(pdf: &[u8]) -> Vec<(u8, u8)> { + use hayro_syntax::object::{Array, Name, Object}; + let file = hayro_syntax::Pdf::new(pdf.to_vec()).unwrap(); + let mut out = Vec::new(); + for st in file.objects().into_iter().filter_map(|o| o.into_stream()) { + let d = st.dict(); + if !d.get::>(b"Subtype").is_some_and(|n| n.as_ref() == b"Image") || d.get::(b"BitsPerComponent") != Some(8) { + continue; + } + let Some(cs) = d.get::>(b"ColorSpace") else { continue }; + let mut it = cs.iter::>(); + if !it.next().and_then(|o| o.into_name()).is_some_and(|n| n.as_ref() == b"DeviceN") { + continue; + } + let Some(Object::Array(names)) = it.next() else { continue }; + let names: Vec = names.iter::>().map(|n| n.as_str().to_string()).collect(); + let (Some(k), Some(g)) = (names.iter().position(|n| n == "Black"), names.iter().position(|n| n == "Gold")) else { continue }; + let data = st.decoded().unwrap(); + out.extend(data.chunks(names.len()).filter(|px| px.len() == names.len()).map(|px| (px[k], px[g]))); + } + out +} + +/// The shadow coverage gives the rendered pixel back when the PDF draws the object over the +/// shadow, also under a partly transparent object (an anti-aliased edge, a soft-alpha image). +#[test] +fn shadow_coverage_composites_back() { + for a in (0..=255u32).step_by(15) { + for s in (0..=255u32).step_by(15) { + let over = |c: u32| a + (c * (255 - a) + 127) / 255; + let with = over(s) as u8; + let c = u32::from(super::shadow_coverage(with, a as u8)); + assert!(over(c).abs_diff(u32::from(with)) <= 1, "object {a}, shadow {s}: coverage {c}"); + } + } + assert_eq!(super::shadow_coverage(255, 255), 0); + assert_eq!(super::shadow_coverage(10, 20), 0); +} + +/// PDF/X-1a through craft-prepress: a soft drop shadow over a spot, a 50% object and a +/// knockout group leave no transparency and no RGB; the shadow is flattened in the page's +/// inks (Black over the spot), not exported as an RGB image of the object; text stays text and +/// overprint stays `/op`; `flatten` sets the resolution. +#[test] +fn x1a_export_flattens_in_inks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap(); + let bg = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [bg], "swatch": "Gold"})).unwrap(); + let shadowed = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [shadowed], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.dropShadow", &json!({"ids": [shadowed], "on": true, "distance": 8, "size": 6})).unwrap(); + let half = s.execute("frame.create", &json!({"rect": [300, 72, 400, 172]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [half], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [half], "opacity": 0.5})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [72, 300, 172, 400]})).unwrap()["id"].clone(); + let b = s.execute("frame.create", &json!({"rect": [122, 350, 222, 450]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [a, b], "swatch": "C=0 M=0 Y=100 K=0"})).unwrap(); + s.execute("object.opacity", &json!({"ids": [a, b], "opacity": 0.5})).unwrap(); + let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].clone(); + s.execute("object.transparencyGroup", &json!({"ids": [g], "knockout": true})).unwrap(); + let op = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [op], "swatch": "C=0 M=100 Y=0 K=0"})).unwrap(); + s.execute("object.attributes", &json!({"ids": [op], "overprintFill": true})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 500, 400, 560], "content": "text", "text": "Live text", "caret": false})).unwrap(); + + let r = s.execute("file.exportPdf", &json!({"standard": "x1a", "flatten": "low"})).unwrap(); + let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + let text = String::from_utf8_lossy(&pdf).to_string(); + assert_eq!(designcraft_pdf::check_pdfx1a(&pdf), Vec::::new()); + assert!(pdf.starts_with(b"%PDF-1.4") && text.contains("/GTS_PDFXVersion(PDF/X-1:2003)") && text.contains("/S/GTS_PDFX")); + assert!(text.contains("/GTS_PDFXConformance(PDF/X-1a:2003)"), "the conformance level"); + let warnings = r["warnings"].to_string(); + assert!(!warnings.contains("soft effects"), "the shadow isn't an RGB image of the object: {warnings}"); + assert!(warnings.contains("at 72 ppi"), "{warnings}"); + let spaces = image_spaces(&pdf); + assert!(spaces.iter().all(|cs| !cs.contains("RGB") && !cs.contains("ICC")), "{spaces:?}"); + assert!(spaces.iter().any(|cs| cs.starts_with("DeviceN") && cs.contains("Black") && cs.contains("Gold")), "the shadow over the spot: {spaces:?}"); + // The shadow multiplies: the spot stays at 100% where the shadow's black is. + let shadowed: Vec<(u8, u8)> = black_and_gold(&pdf).into_iter().filter(|(k, _)| *k > 100).collect(); + assert!(!shadowed.is_empty(), "no shadow pixels"); + assert!(shadowed.iter().all(|(_, gold)| *gold >= 250), "the spot under the shadow: {:?}", &shadowed[..shadowed.len().min(8)]); + assert!(text.contains("/op true") && text.contains("/OPM 1"), "overprint"); + assert!(text.contains("/FontFile"), "the text is still text"); + // The default resolution. + let r = s.execute("file.exportPdf", &json!({"standard": "x1a"})).unwrap(); + assert!(r["warnings"].to_string().contains("at 300 ppi"), "{}", r["warnings"]); +} + +#[test] +fn flattener_rasterises_spreads_with_transparency() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [id]})).unwrap(); + // Opaque: nothing to flatten. + let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("flattened"))); + s.execute("object.opacity", &json!({"ids": [id], "opacity": 0.5})).unwrap(); + // Text away from the transparency stays live text. + s.execute("frame.create", &json!({"rect": [72, 400, 400, 450], "content": "text", "text": "Still vector", "caret": false})).unwrap(); + let r = s.execute("file.exportPdf", &json!({"flatten": "low"})).unwrap(); + let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + assert!(String::from_utf8_lossy(&pdf).contains("/FontFile"), "the text is still text"); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flattened: 1 ")), "{r}"); + assert_eq!(r["pages"], 3); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + assert_eq!(designcraft_render::pdf_page_count(&bytes), Some(3)); + // The document itself is untouched. + assert!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).is_some()); + assert!(s.execute("file.exportPdf", &json!({"flatten": 5})).is_err()); + // Interactive view options land in the catalog and on the pages. + let r = s.execute("file.exportPdf", &json!({"fullScreen": true, "pageLayout": "single", "view": "fitPage", "advanceSeconds": 5})).unwrap(); + let t = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); + assert!( + t.contains("/PageMode/FullScreen") && t.contains("/PageLayout/SinglePage") && t.contains("/OpenAction[") && t.contains("/Dur 5.00"), + "view options" + ); + assert!(s.execute("file.exportPdf", &json!({"pageLayout": "sideways"})).is_err()); + // Unflattened, the page blends in the document's blend space (CMYK for print). + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let text = String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).to_string(); + assert_eq!(text.matches("/S/Transparency/CS/DeviceCMYK").count(), 1, "only the page with transparency"); +} + +#[test] +fn objects_with_soft_effects_keep_their_text_wrap_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Words wrap around the objects on this page. ".repeat(60); + let t = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text", "text": text, "caret": false})).unwrap()["id"] + .as_u64() + .unwrap(); + // A feathered frame with a contour wrap of the frame itself. + let a = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [a]})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [a], "mode": "contour", "contour": "sameAsClipping", "offset": 6})).unwrap(); + // A feathered frame whose placed graphic has a bounding-box wrap. + let png = designcraft_render::Rendered { width: 40, height: 40, pixels: vec![90; 40 * 40 * 4] }.to_png(); + let b = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "a.png", "frame": b})).unwrap(); + s.execute("object.textWrap", &json!({"ids": [b], "content": true, "mode": "boundingBox", "offset": 6})).unwrap(); + s.execute("object.feather", &json!({"ids": [a, b], "width": 14})).unwrap(); + + let d = &s.doc().unwrap().doc; + let sid = d.item(designcraft_doc::ItemId(t)).and_then(|it| it.text_frame()).unwrap().story; + let before = designcraft_compose::frame_specs(d, sid)[0].exclusions.clone(); + assert_eq!(before.len(), 2); + let (fx, n) = super::rasterize_effects(d, &s.cache, &Default::default()); + assert_eq!(n, 2); + let fx = fx.unwrap(); + assert_eq!(designcraft_compose::frame_specs(&fx, sid)[0].exclusions, before); + // The objects that keep the wrap don't print; their images do. + let sp = &fx.spreads[0]; + assert_eq!(sp.items.iter().filter(|it| it.nonprinting).count(), 2); + assert_eq!(sp.items.iter().filter(|it| !it.nonprinting && it.graphic().is_some()).count(), 2); +} + +#[test] +fn export_story_as_text_and_rtf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap(); + assert!(s.execute("file.exportText", &json!({"frame": r["id"]})).unwrap()["text"] == "One\r\nTwo"); + let html = s.execute("file.exportHtml", &json!({})).unwrap(); + assert!(html["text"].as_str().unwrap().contains("One

")); + let tagged = s.execute("file.exportText", &json!({"frame": r["id"], "format": "tagged"})).unwrap(); + assert!(tagged["text"].as_str().unwrap().contains(" Vec { - vec![ - cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), - "{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}", - always, file_new), - cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), - cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| { - let mut presets: Vec = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect(); - presets.extend( - s.prefs - .document_presets - .iter() - .map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})), - ); - Ok(Value::Array(presets)) - }), - cmd!(noundo "file.savePreset", "Save Document Preset", [], None, - "{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused", - always, save_preset), - cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| { - let name = str_param(p, "name").unwrap_or_default().trim().to_string(); - let before = s.prefs.document_presets.len(); - s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); - if s.prefs.document_presets.len() == before { - return Err(bad("file.deletePreset", format!("no saved preset `{name}`"))); - } - ok() - }), - cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), - "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", - always, file_open), - cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), - cmd!(noundo "file.openRecent", "Open Recent File", [], None, - "{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed", - has_recent, file_open_recent), - cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| { - Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect())) - }), - cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| { - s.prefs.recent_files.clear(); - ok() - }), - cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), - cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), - cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { - let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; - let path = with_document_extension(path); - let mut copy = (*s.doc()?.doc).clone(); - super::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); - let bytes = to_bytes_with_preview(©, &s.cache)?; - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": bytes.len()})) - }), - cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), - cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { - let dir = recovery_dir(s, p, FolderAccess::Read)?; - Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) - }), - cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { - let dir = recovery_dir(s, p, FolderAccess::Write)?; - Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) - }), - cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { - // Reopening removes the entries it read. - let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; - Ok(json!({"opened": crate::recovery::open(s, &dir)?})) - }), - cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { - let st = s.doc()?; - // Preview fill is session state. Serialize the unfilled template. - let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); - let bytes = to_bytes(doc)?; - Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) - }), - cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { - let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0); - s.close_document(i); - ok() - }), - cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| { - s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); - ok() - }), - cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, - "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", super::has_selection, snippet_export), - cmd!(query "place.styles", "Import Options: Styles", [], None, - "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", - has_doc, |s, p| { - let (bytes, name, _) = read_source(p)?; - let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; - let st = &s.doc()?.doc.styles; - let conflicts: Vec<&str> = imp - .para_styles - .iter() - .filter(|x| st.para(&x.name).is_some()) - .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) - .map(|x| x.name.as_str()) - .collect(); - Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) - }), - cmd!( - "snippet.place", - "Place Snippet", - [], - None, - "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", - has_doc, - snippet_place - ), - cmd!( - "place.load", - "Load Place Cursor", - [], - None, - "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", - has_doc, - place_load - ), - cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), - cmd!( - "file.place", - "Place…", - ["File"], - Some("Cmd+D"), - "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", - has_doc, - file_place - ), - ] -} +mod bytes; +mod new; +mod open; +mod place; +mod registry; +mod save; +mod snippets; + +pub use bytes::{DOCUMENT_SNIFF_BYTES, base64_decode, base64_encode, from_bytes, is_document, to_bytes, with_document_extension}; +pub(crate) use open::load_document_fonts; +pub use open::{RecentPreview, recent_preview}; +pub(crate) use place::{crop_pdf, read_source, validate_placement}; +pub use registry::specs; +pub(crate) use snippets::{snippet_bytes, snippet_place}; #[cfg(test)] thread_local! { @@ -148,2024 +31,5 @@ thread_local! { pub(crate) static FAIL_TO_BYTES: std::cell::Cell = const { std::cell::Cell::new(false) }; } -/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file. -pub fn to_bytes(d: &Document) -> Result> { - save_bytes(d, None) -} - -/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture. -fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { - #[cfg(test)] - if FAIL_TO_BYTES.with(std::cell::Cell::get) { - return Err(EngineError::Other("could not write the document: injected failure".into())); - } - designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}"))) -} - -/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so -/// the file opens from the file manager. A path without a file name is left for the write to fail. -pub fn with_document_extension(path: &str) -> String { - let p = std::path::Path::new(path); - let ext = p.extension().and_then(std::ffi::OsStr::to_str); - if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { - return path.to_string(); - } - format!("{path}.{}", designcraft_format::EXTENSION) -} - -/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. -/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their -/// `mimetype` entry, as both formats write them. -pub fn is_document(bytes: &[u8]) -> bool { - designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes) -} - -/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry. -pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10; - -/// The longer side of the first-page picture saved in the file, in pixels. -const PREVIEW_PX: f64 = 320.0; - -/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file -/// in its recent files. A page that can't be drawn just leaves the picture out. -fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result> { - let preview = d.page_loc(0).and_then(|(si, pi)| { - let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); - let side = b.width().max(b.height()); - (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) - }); - let png = - preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); - save_bytes(d, png.as_deref()) -} - -pub fn from_bytes(b: &[u8]) -> Result { - load_bytes(b).map(|(d, _)| d) -} - -/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the -/// open, with a warning for each repair. -fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { - let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; - let warnings = d.fix_parent_bases(); - Ok((d, warnings)) -} - -/// The longest saved preset name, and how many presets are kept. -const MAX_PRESET_NAME: usize = 100; -const MAX_PRESETS: usize = 500; - -/// A built-in document preset or a saved one, by name (case ignored). -fn find_preset(s: &Session, name: &str) -> Option { - NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) -} - -/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. -fn new_document_settings(s: &Session, p: &Value) -> Result { - let mut nd = match str_param(p, "preset") { - Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, - None => NewDocument::default(), - }; - apply_settings(&mut nd, p); - check_size("file.new", &nd)?; - Ok(nd) -} - -/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a -/// paragraph's last word or across a column. Opened and imported files keep their own values, -/// which is why `ParaProps` keeps the IDML defaults (on). -fn new_document(nd: &NewDocument) -> Document { - let mut d = Document::new(nd); - if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { - st.para.hyph_capitalized = Some(false); - st.para.hyph_last_word = Some(false); - st.para.hyph_across_column = Some(false); - } - d -} - -fn file_new(s: &mut Session, p: &Value) -> Result { - let mut nd = new_document_settings(s, p)?; - s.untitled += 1; - nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); - let d = new_document(&nd); - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) -} - -impl Session { - /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while - /// the New Document window is open. It replaces the previous preview document in its tab and - /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses - /// leave the previous preview as it was. Returns the preview document's uid. - pub fn preview_new_document(&mut self, p: &Value) -> Result { - let mut nd = new_document_settings(self, p)?; - let previous = match self.new_document_preview { - Some((_, previous)) => previous, - None => self.active().map(|d| d.uid), - }; - let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); - // A preview edited or saved by a menu or script while the window is open has become a - // document of its own: it stays, and the preview gets a tab of its own. - let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); - if replace.is_none() && shown.is_some() { - self.untitled += 1; - } - nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); - let st = DocState::new(new_document(&nd), None); - let uid = st.uid; - match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { - Some((i, d)) => { - *d = st; - self.active = Some(i); - } - None => { - // Pushed without `add_document`: the document that was active stays as it is. - self.docs.push(st); - self.active = Some(self.docs.len() - 1); - } - } - self.new_document_preview = Some((uid, previous)); - Ok(uid) - } - - /// The uid of New Document's preview document, while one is shown. - pub fn new_document_preview(&self) -> Option { - self.new_document_preview.map(|(uid, _)| uid) - } - - /// End New Document's preview. Its document goes, and if it was still the active document, - /// the one that was active before it is active again. A preview that a menu or script has - /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the - /// document removed. Without a preview it does nothing. - pub fn end_new_document_preview(&mut self) -> Option { - let (uid, previous) = self.new_document_preview.take()?; - let i = self.docs.iter().position(|d| d.uid == uid)?; - let d = self.docs.get(i)?; - if d.is_dirty() || d.path.is_some() { - self.untitled += 1; - return None; - } - self.docs.remove(i); - self.active = match self.active { - Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), - Some(a) if a > i => Some(a - 1), - a => a, - }; - Some(uid) - } - - /// Create in the New Document window, with the settings the preview shows: the preview - /// document becomes the new document, as `file.new` would make it (the next Untitled number, - /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. - pub fn keep_new_document_preview(&mut self) -> Option { - let (uid, _) = self.new_document_preview?; - let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; - self.new_document_preview = None; - self.untitled += 1; - if let Some(d) = self.docs.get_mut(i) { - d.history.undo.clear(); - d.history.redo.clear(); - } - self.active = Some(i); - Some(i) - } -} - -fn save_preset(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); - if name.chars().count() > MAX_PRESET_NAME { - return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters"))); - } - if NewDocument::from_preset(&name).is_some() { - return Err(bad("file.savePreset", format!("`{name}` is a built-in preset"))); - } - let mut nd = NewDocument::default(); - apply_settings(&mut nd, p); - check_size("file.savePreset", &nd)?; - nd.title = name.clone(); - let presets = &mut s.prefs.document_presets; - presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); - if presets.len() >= MAX_PRESETS { - return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets"))); - } - presets.push(nd); - Ok(json!({"name": name})) -} - -fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> { - if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) { - return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)")); - } - Ok(()) -} - -/// The document settings in `p` (as file.new takes them) over `nd`. -fn apply_settings(nd: &mut NewDocument, p: &Value) { - if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { - nd.intent = intent; - } - if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { - nd.units = units; - } - nd.width = f64_or(p, "width", nd.width); - nd.height = f64_or(p, "height", nd.height); - nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); - nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); - nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); - nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); - nd.gutter = f64_or(p, "gutter", nd.gutter); - nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame); - match p.get("margins") { - Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)), - Some(v @ Value::Object(_)) => { - if let Ok(m) = serde_json::from_value(v.clone()) { - nd.margins = m; - } - } - _ => {} - } - if let Some(b) = edges(p, "bleed") { - nd.bleed = b; - } - if let Some(b) = edges(p, "slug") { - nd.slug = b; - } -} - -/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. -fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { - let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; - match p.get(key)? { - Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { - [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), - _ => None, - }, - v => v.as_f64().map(|b| [clean(b); 4]), - } -} - -fn file_sample(s: &mut Session, _p: &Value) -> Result { - let d = crate::sample::magazine(); - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i})) -} - -fn has_recent(s: &Session) -> std::result::Result<(), String> { - if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) } -} - -/// A recent file's first-page picture as straight RGBA, for the start screen. -pub struct RecentPreview { - pub width: u32, - pub height: u32, - pub rgba: Vec, -} - -/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't -/// ours, or was saved before files had one (it gets one when saved again). -pub fn recent_preview(path: &str) -> Option { - #[cfg(not(target_arch = "wasm32"))] - { - // Only the zip directory and the preview entry are read, not the whole document. - let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?; - let img = designcraft_images::decode_rgba(&png)?; - let (width, height) = img.dimensions(); - Some(RecentPreview { width, height, rgba: img.into_raw() }) - } - #[cfg(target_arch = "wasm32")] - { - let _ = path; - None - } -} - -/// One File › Open Recent row: the path, its file name and whether it is still there. -fn recent_entry(path: &str) -> Value { - let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); - #[cfg(not(target_arch = "wasm32"))] - let exists = crate::io::is_file(path); - #[cfg(target_arch = "wasm32")] - let exists = false; - json!({"path": path, "name": name, "exists": exists}) -} - -fn file_open_recent(s: &mut Session, p: &Value) -> Result { - let index = - p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?; - let path = s - .prefs - .recent_files - .get(index) - .cloned() - .ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?; - #[cfg(not(target_arch = "wasm32"))] - if !crate::io::is_file(&path) { - return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected"))); - } - file_open(s, &json!({"path": path})) -} - -/// `file.open`: the document opens and moves to the front of File › Open Recent. -fn file_open(s: &mut Session, p: &Value) -> Result { - let r = open_path(s, p)?; - if let Some(path) = str_param(p, "path") { - s.prefs.note_recent_file(path); - } - Ok(r) -} - -fn open_path(s: &mut Session, p: &Value) -> Result { - let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; - #[cfg(not(target_arch = "wasm32"))] - { - let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; - // The content decides first: a file's extension may be missing or wrong. The extension - // then routes files whose content isn't recognised to the reader that explains why. - let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); - let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); - if designcraft_idml::is_idml(&bytes) || (!native && (super::interchange::is_indd(path, &bytes) || ext == "idml")) { - return super::interchange::open_idml(s, p); - } - if !native && ext != designcraft_format::EXTENSION { - return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); - } - let (mut d, mut warnings) = load_bytes(&bytes)?; - super::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path))); - super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); - let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path); - warnings.extend(font_warnings); - warnings.extend(super::style::grep_style_warnings(&d)); - let mut st = DocState::new(d, Some(path.to_string())); - st.fonts = fonts; - let i = s.add_document(st); - Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (s, path); - Err(EngineError::Other("use file.openBytes on the web".into())) - } -} - -/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone -/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each -/// font file skipped). Nothing on the web (no folders). -pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { - #[cfg(not(target_arch = "wasm32"))] - { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; - // A document's folder is the agent's to name (a recovery entry or a book lists paths too): - // the fonts crate asks the automation roots about the folder and each font file, so fonts - // outside the read root stay unloaded. - let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); - d.font_scope = r.scope; - let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); - (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (d, path); - (None, 0, Vec::new()) - } -} - -/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it -/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins. -#[cfg(not(target_arch = "wasm32"))] -fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - let exact = dir.join(DOCUMENT_FONTS_FOLDER); - if crate::io::is_dir(&exact) { - return exact; - } - crate::io::read_dir(dir) - .into_iter() - .flatten() - .flatten() - .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) - .map_or(exact, |e| e.path()) -} - -fn file_open_bytes(s: &mut Session, p: &Value) -> Result { - let b = base64_decode(str_param(p, "base64").unwrap_or("")); - // IDML packages are recognised by their stored `mimetype` first entry, INDD files by their - // signature (or their name). - if designcraft_idml::is_idml(&b) || super::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) { - return super::interchange::open_idml(s, p); - } - let (mut d, warnings) = load_bytes(&b)?; - super::datamerge::resolve_sources_on_open(&mut d, None); - if let Some(n) = str_param(p, "name") { - d.title = n.to_string(); - } - let i = s.add_document(DocState::new(d, None)); - Ok(json!({"index": i, "warnings": warnings})) -} - -fn file_save(s: &mut Session, p: &Value) -> Result { - let st = s.doc_mut()?; - // Saving to the document's own file keeps its name, even one opened without the extension. - let path = str_param(p, "path") - .map(with_document_extension) - .or_else(|| st.path.clone()) - .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; - // Before anything about the document changes: a refused save leaves it as it was. - crate::file_access::check_write(&path).map_err(EngineError::Other)?; - // Prepare paths for the destination without changing the live document until persistence succeeds. - let mut saved = (*st.doc).clone(); - super::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); - let paths_changed = saved.data_merge != st.doc.data_merge; - let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; - let bytes = to_bytes_with_preview(&saved, &s.cache)?; - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; - let st = s.doc_mut()?; - if paths_changed { - st.doc = saved; - st.bump_revision(); - } - st.path = Some(path.clone()); - st.saved_revision = st.revision; - st.saved_doc = st.doc.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - let recovery_dir = s.recovery_dir.clone(); - let index = s.active_index().ok_or(EngineError::NoDocument)?; - crate::recovery::discard_document(s, index, recovery_dir.as_deref()); - s.prefs.note_recent_file(&path); - } - Ok(json!({"path": path, "bytes": bytes.len()})) -} - -fn file_place(s: &mut Session, p: &Value) -> Result { - // The file's size and time before it is read (a linked text file remembers them). - let mut before = None; - let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { - (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) - } else if let Some(path) = str_param(p, "path") { - before = super::datamerge::fingerprint_from_path(path); - #[cfg(not(target_arch = "wasm32"))] - let b = crate::io::read_file(path).map_err(EngineError::Other)?; - #[cfg(target_arch = "wasm32")] - let b: Vec = vec![]; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - (b, name, Some(path.to_string())) - } else { - s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); - return ok(); - }; - // A page of another layout (IDML or DesignCraft): its objects, as one group. - let lname = name.to_lowercase(); - if lname.ends_with(".idml") || lname.ends_with(".designcraft") { - return place_layout_page(s, p, &name, &bytes); - } - // Keep the EPS source for link comparison; rendering uses its preview at the bounding box size. - let eps_size = eps_size(&bytes, "file.place")?; - // Video and sound: a media frame (Window › Interactive › Media). - if let Some(mime) = designcraft_doc::media_mime(&name) { - return super::media::place_media(s, p, name, mime, bytes, link); - } - // Text files (plain, Word, RTF) flow into frames. - if designcraft_textimport::is_text_file(&name) { - return super::place_text::place_text(s, p, &name, &bytes, link, before); - } - let want_w = p - .get("width") - .filter(|value| !value.is_null()) - .map(|value| { - value - .as_f64() - .filter(|width| width.is_finite() && *width > 0.0) - .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) - }) - .transpose()?; - // Image Import Options: which page of a PDF (1-based). - if designcraft_render::is_pdf(&bytes) { - designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; - } - let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { - Some(n) if designcraft_render::is_pdf(&bytes) => { - let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); - let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); - let one_based = usize::try_from(n).map_err(|_| invalid_page())?; - if one_based == 0 || one_based > count { - return Err(invalid_page()); - } - let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; - u32::try_from(zero_based).map_err(|_| invalid_page())? - } - _ => 0, - }; - let (pw, ph) = match pdf_page { - 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, - n => designcraft_render::pdf_page_size(&bytes, n as usize) - .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) - .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, - }; - // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages - // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. - let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); - // Image Import Options › Crop to: the graphic spans that box of the PDF page. - let is_pdf = designcraft_render::is_pdf(&bytes); - let pdf_crop = match str_param(p, "pdfCrop") { - Some(k) if is_pdf => PdfCrop::from_name(k) - .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, - _ => PdfCrop::Crop, - }; - let mime = designcraft_render::image_mime(&bytes).to_string(); - let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; - // The graphic's natural size: the box in points (the whole page when the box can't be found, - // and then the document says so). - let (bw, bh) = match crop_pdf(&mut asset) { - Some(size) => size, - None => { - asset.pdf_crop = PdfCrop::Crop; - (nw, nh) - } - }; - let target_frame = super::id_param(p, "frame").or_else(|| { - if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { - return None; - } - let st = s.active()?; - st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) - }); - // `"spread": null` counts as omitted, like the other placement parameters. - let spread = match p.get("spread") { - Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), - _ => super::spread_param(p, "file.place")?, - }; - let lid = s.doc()?.active_layer; - let px = p.get("x").and_then(Value::as_f64); - let py = p.get("y").and_then(Value::as_f64); - if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { - return load_cursor(s, asset, (bw, bh)); - } - s.edit(|d, sel| { - let fresh = AssetId(d.alloc()); - let aid = d.add_asset(Asset { id: fresh, ..asset }); - // The width the whole page (or image) gets; the height follows the proportions. - let w = match want_w { - Some(w) => w, - None => { - let page_w = d.settings.page_width * 0.6; - nw.min(page_w) - } - }; - let id = if let Some(fid) = target_frame { - let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; - let r = it.inner_bounds(); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - let k = (r.width() / bw).max(r.height() / bh); - let (gw, gh) = (bw * k, bh * k); - let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); - validate_placement((bw, bh), r, xf, "file.place")?; - it.content = Content::Graphic(Graphic { - asset: aid, - size: (bw, bh), - xf, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - fid - } else { - let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?; - let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); - let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); - let id = ItemId(d.alloc()); - let k = w / nw; - let r = super::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?; - let xf = Affine::translate((x, y)) * Affine::scale(k); - validate_placement((bw, bh), r, xf, "file.place")?; - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); - it.object_style = d.styles.default_graphic_frame.clone(); - if let Some(e) = d.styles.object_effects(&it.object_style) { - it.effects = e.clone(); - } - it.content = Content::Graphic(Graphic { - asset: aid, - size: (bw, bh), - xf, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(spread, it, None)?; - id - }; - *sel = Selection::items(vec![id]); - Ok(json!({"id": id.0, "asset": aid.0})) - }) -} - -const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - -pub fn base64_encode(data: &[u8]) -> String { - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - out.push(B64[(n >> 18) as usize & 63] as char); - out.push(B64[(n >> 12) as usize & 63] as char); - out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); - out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); - } - out -} - -pub fn base64_decode(s: &str) -> Vec { - let mut out = Vec::with_capacity(s.len() * 3 / 4); - let mut buf = 0u32; - let mut bits = 0; - for b in s.bytes() { - let v = match b { - b'A'..=b'Z' => b - b'A', - b'a'..=b'z' => b - b'a' + 26, - b'0'..=b'9' => b - b'0' + 52, - b'+' | b'-' => 62, - b'/' | b'_' => 63, - _ => continue, - } as u32; - buf = buf << 6 | v; - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((buf >> bits) as u8); - } - } - out -} - -/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in -/// points. The crop box is the whole page and has none; nor has a page that can't be read, which -/// keeps its crop choice (a missing link relinked later gets its box then). -pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { - a.pdf_box = None; - if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { - return None; - } - let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; - a.pdf_box = Some(frac); - Some(size) -} - -pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { - if let Some(b) = str_param(p, "base64") { - return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); - } - let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; - #[cfg(not(target_arch = "wasm32"))] - let b = crate::io::read_file(path).map_err(EngineError::Other)?; - #[cfg(target_arch = "wasm32")] - let b: Vec = vec![]; - let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); - Ok((b, name, Some(path.to_string()))) -} - -fn eps_size(bytes: &[u8], command: &str) -> Result> { - if !designcraft_images::is_eps(bytes) { - return Ok(None); - } - designcraft_images::eps_size(bytes) - .map(Some) - .ok_or_else(|| bad(command, "the EPS has a missing or invalid bounding box (dimensions must be 0.001–20,000 points)")) -} - -/// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls. -pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> { - let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0; - if !size.0.is_finite() - || !size.1.is_finite() - || size.0 <= 0.0 - || size.1 <= 0.0 - || !valid_rect(frame) - || !xf.as_coeffs().iter().all(|n| n.is_finite()) - || !valid_rect(xf.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1))) - { - return Err(bad(command, "image placement exceeds finite geometry limits")); - } - Ok(()) -} - -fn place_load(s: &mut Session, p: &Value) -> Result { - let (bytes, name, link) = read_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; - let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; - load_cursor(s, asset, natural) -} - -/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. -fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { - let name = asset.name.clone(); - let (pw, ph) = asset.pixels.unwrap_or_default(); - let aid = s.edit(|d, _| { - asset.id = AssetId(d.alloc()); - Ok(d.add_asset(asset)) - })?; - s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); - s.set_tool("placeGun"); - Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) -} - -fn place_drop(s: &mut Session, p: &Value) -> Result { - let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; - // Undo can remove the loaded graphic's asset. - if !s.doc()?.doc.assets.contains_key(&aid) { - s.loaded = None; - return Err(bad("place.drop", "the loaded graphic is no longer in the document")); - } - let sr = super::spread_param(p, "place.drop")?; - let lid = s.doc()?.active_layer; - let frame = super::id_param(p, "frame"); - let rect = super::rect_param(p, "rect"); - let (x, y) = (super::f64_or(p, "x", 0.0), super::f64_or(p, "y", 0.0)); - let d = &s.doc()?.doc; - let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { - let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); - let k = (r.width() / nw).max(r.height() / nh); - let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); - // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. - (r, xf, Default::default()) - } else { - if d.spread(sr).is_none() { - return Err(bad("place.drop", "no such spread")); - } - // Drag: fit proportionally; click: actual size at the point. - let (r, k) = match rect { - Some(r) if r.width() > 2.0 && r.height() > 2.0 => { - let k = (r.width() / nw).min(r.height() / nh); - (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) - } - _ => (Rect::new(x, y, x + nw, y + nh), 1.0), - }; - (r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default()) - }; - validate_placement((nw, nh), frame_rect, xf, "place.drop")?; - let r = s.edit(|d, sel| { - let graphic = Content::Graphic(Graphic { - asset: aid, - size: (nw, nh), - xf, - clip: None, - auto_fit, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - let id = if let Some(fid) = frame { - d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic; - fid - } else { - let id = ItemId(d.alloc()); - let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); - it.object_style = d.styles.default_graphic_frame.clone(); - if let Some(e) = d.styles.object_effects(&it.object_style) { - it.effects = e.clone(); - } - it.content = graphic; - d.insert_item(sr, it, None)?; - id - }; - *sel = Selection::items(vec![id]); - Ok(json!({"id": id.0})) - })?; - s.loaded = None; - s.set_tool("selection"); - Ok(r) -} - -/// The selection as `.designcraft` snippet bytes. -pub(crate) fn snippet_bytes(s: &mut Session) -> Result> { - to_bytes(&super::edit::clip_doc(s)?) -} - -fn snippet_export(s: &mut Session, p: &Value) -> Result { - let d = super::edit::clip_doc(s)?; - let bytes = to_bytes(&d)?; - match str_param(p, "path") { - Some(path) => { - #[cfg(not(target_arch = "wasm32"))] - crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; - Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) - } - None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), - } -} - -/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, -/// 1-based) grouped, top-left at x/y (default: the source position on the first page). -fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { - const ID: &str = "file.place"; - let src = if name.to_lowercase().ends_with(".idml") { - super::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))? - } else { - from_bytes(bytes)? - }; - let n = src.page_count(); - let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; - if page == 0 || page > n { - return Err(bad(ID, format!("the document has {n} page(s)"))); - } - let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; - let sp = &src.spreads[si]; - let pr = sp.pages[pi].bounds(); - let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); - if ids.is_empty() { - return Err(bad(ID, format!("page {page} has no objects"))); - } - let to = super::spread_param(p, ID)?; - let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), - _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), - }; - let placed = s.edit(|d, sel| { - for sw in &src.swatches { - if d.swatch(&sw.name).is_none() { - d.swatches.push(sw.clone()); - } - } - let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); - let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); - if !missing_p.is_empty() || !missing_c.is_empty() { - let st = d.styles_mut(); - st.paragraph.extend(missing_p); - st.character.extend(missing_c); - } - let new = super::object::duplicate_from(d, &src, &ids, to, off)?; - *sel = Selection::items(new.clone()); - Ok(new) - })?; - if placed.len() > 1 { - let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; - return Ok(json!({"id": g["id"], "items": placed.len()})); - } - Ok(json!({"id": placed[0].0, "items": 1})) -} - -pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { - let (bytes, _, _) = read_source(p)?; - let src = from_bytes(&bytes)?; - let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); - if ids.is_empty() { - return Err(bad("snippet.place", "the snippet is empty")); - } - let sr = super::spread_param(p, "snippet.place")?; - let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); - let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { - (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), - _ => designcraft_geom::Vec2::ZERO, - }; - s.edit(|d, sel| { - // Bring over styles and swatches the snippet uses that this document lacks. - for sw in &src.swatches { - if d.swatch(&sw.name).is_none() { - d.swatches.push(sw.clone()); - } - } - let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); - let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); - if !missing_p.is_empty() || !missing_c.is_empty() { - let st = d.styles_mut(); - st.paragraph.extend(missing_p); - st.character.extend(missing_c); - } - let new = super::object::duplicate_from(d, &src, &ids, sr, off)?; - *sel = Selection::items(new.clone()); - Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) - }) -} - -/// Which access a recovery command needs to the folder it is given. -#[derive(Clone, Copy)] -enum FolderAccess { - Read, - Write, - ReadWrite, -} - -/// The recovery folder: `dir` when the command names one, else the session's own (set by the -/// host application, so not the automation roots' business). A named folder is checked against -/// the roots for `access`. -fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { - #[cfg(target_arch = "wasm32")] - { - let _ = (s, p, access); - Err(EngineError::Other("no recovery folder on the web".into())) - } - #[cfg(not(target_arch = "wasm32"))] - { - let Some(named) = str_param(p, "dir") else { - return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); - }; - if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { - crate::file_access::check_read(named).map_err(EngineError::Other)?; - } - if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { - crate::file_access::check_write(named).map_err(EngineError::Other)?; - } - Ok(std::path::PathBuf::from(named)) - } -} - -fn can_revert(s: &Session) -> std::result::Result<(), String> { - match s.active() { - Some(d) if d.path.is_some() && d.is_dirty() => Ok(()), - Some(d) if d.path.is_none() => Err("the document has never been saved".into()), - Some(_) => Err("no changes since the last save".into()), - None => Err("no document open".into()), - } -} - -fn file_revert(s: &mut Session, _: &Value) -> Result { - let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - #[cfg(not(target_arch = "wasm32"))] - { - let mut d = if path.to_ascii_lowercase().ends_with(".idml") { - let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; - super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))? - } else { - from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? - }; - // As opened again: the fonts beside it now (unchanged files are shared, not read again). - let (fonts, _, _) = load_document_fonts(&mut d, &path); - let uid = s.doc()?.uid; - let recovery_dir = s.recovery_dir.clone(); - let index = s.active_index().ok_or(EngineError::NoDocument)?; - let mut fresh = DocState::new(d, Some(path.clone())); - fresh.fonts = fonts; - fresh.uid = uid; - // A new state has a revision no state had before, so caches don't show the old document. - crate::recovery::discard_document(s, index, recovery_dir.as_deref()); - *s.doc_mut()? = fresh; - Ok(json!({"path": path})) - } - #[cfg(target_arch = "wasm32")] - { - let _ = (s, path); - Err(EngineError::Other("revert isn't available on the web".into())) - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod image_place_tests { - use designcraft_doc::{Content, ItemId}; - use designcraft_geom::Rect; - use serde_json::{Value, json}; - - use crate::Session; - - pub(super) fn image_file() -> std::io::Result { - image_file_with_color(30) - } - - fn image_file_with_color(red: u8) -> std::io::Result { - let file = tempfile::Builder::new().suffix(".png").tempfile()?; - let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); - std::fs::write(file.path(), png)?; - Ok(file) - } - - #[test] - fn consecutive_path_placements_create_distinct_graphics() { - let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; - let mut session = Session::new(); - session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - let mut placements = Vec::new(); - let mut originals = Vec::new(); - - for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { - let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); - let id = ItemId(placed["id"].as_u64().unwrap()); - assert!( - placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), - "each explicit placement needs a distinct frame: {placed}" - ); - let document = &session.doc().unwrap().doc; - let item = document.item(id).unwrap(); - assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); - assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); - originals.push(serde_json::to_value(item).unwrap()); - placements.push(placed); - } - - let document = &session.doc().unwrap().doc; - assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); - assert_eq!(document.assets.len(), 3); - for (placed, original) in placements.iter().zip(originals) { - assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); - } - let links = session.execute("links.list", &json!({})).unwrap(); - assert_eq!(links.as_array().unwrap().len(), 3); - for (placed, image) in placements.into_iter().zip(&images) { - let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); - assert_eq!(row["path"], json!(image.path())); - assert_eq!(row["uses"].as_array().unwrap().len(), 1); - assert_eq!(row["uses"][0]["id"], placed["id"]); - } - } - - #[test] - fn individual_placement_parameters_bypass_selected_frames() { - let image = image_file().unwrap(); - for graphic in [false, true] { - for (key, value, expected) in [ - ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), - ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), - ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), - ("width", 0, Rect::ZERO), - ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), - ] { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - if graphic { - session.execute("file.place", &json!({"path": image.path()})).unwrap(); - } - let id = ItemId(selected["id"].as_u64().unwrap()); - let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); - let mut params = json!({"path": image.path()}); - params[key] = json!(value); - - let result = session.execute("file.place", ¶ms); - if key == "width" && value == 0 { - assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); - continue; - } - let placed = result.unwrap(); - - assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); - let document = &session.doc().unwrap().doc; - assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); - let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), expected); - let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; - let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); - assert!(scale_x > 0.0 && scale_y > 0.0); - } - } - } - - #[test] - fn explicit_frame_wins_over_placement_and_selection() { - let image = image_file().unwrap(); - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); - - let placed = - session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); - - assert_eq!(placed["id"], target["id"]); - let document = &session.doc().unwrap().doc; - let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - assert!(matches!(item.content, Content::Graphic(_))); - assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); - assert_eq!(document.spreads[0].items.len(), 2); - } - - fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { - let image = image_file()?; - for graphic in [false, true] { - for explicit_frame in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({}))?; - let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; - if graphic { - session.execute("file.place", &json!({"path": image.path()}))?; - } - let mut params = json!({"path": image.path(), "width": width}); - if explicit_frame { - params["frame"] = target["id"].clone(); - session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; - } - let before = session.doc()?; - let document = before.doc.clone(); - let selection = before.selection.clone(); - let revision = before.revision; - let assets = document.assets.len(); - - let result = session.execute("file.place", ¶ms); - - assert!( - matches!(&result, Err(crate::EngineError::BadParams { .. })), - "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" - ); - let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; - let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; - assert_eq!(cmd, "file.place"); - assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); - let after = session.doc()?; - assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); - assert_eq!(after.selection, selection); - assert_eq!(after.revision, revision); - assert_eq!(after.doc.assets.len(), assets); - } - } - Ok(()) - } - - #[test] - fn width_rejected_when_zero() { - assert_width_rejected(json!(0)).unwrap(); - } - - #[test] - fn width_rejected_when_negative() { - assert_width_rejected(json!(-80)).unwrap(); - } - - #[test] - fn width_rejected_when_non_numeric() { - for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { - assert_width_rejected(width).unwrap(); - } - } - - fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { - let image = image_file()?; - let previous_image = image_file_with_color(31)?; - for graphic in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({}))?; - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; - let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; - let mut params = json!({"path": image.path()}); - for key in keys { - params[*key] = Value::Null; - } - - let placed = session.execute("file.place", ¶ms)?; - - assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); - if let Some(previous) = previous { - assert_ne!(placed["asset"], previous["asset"]); - } - let document = &session.doc()?.doc; - let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); - let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); - let Content::Graphic(content) = &item.content else { - return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); - }; - assert_eq!(json!(content.asset.0), placed["asset"]); - assert_eq!(document.spreads[0].items.len(), 1); - } - Ok(()) - } - - #[test] - fn selection_used_when_x_null() { - assert_null_placement_uses_selection(&["x"]).unwrap(); - } - - #[test] - fn selection_used_when_y_null() { - assert_null_placement_uses_selection(&["y"]).unwrap(); - } - - #[test] - fn selection_used_when_spread_null() { - assert_null_placement_uses_selection(&["spread"]).unwrap(); - } - - #[test] - fn selection_used_when_width_null() { - assert_null_placement_uses_selection(&["width"]).unwrap(); - } - - #[test] - fn selection_used_when_all_placement_parameters_null() { - assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); - } - - #[test] - fn implicit_placement_fills_empty_and_replaces_graphic_selection() { - let image = image_file().unwrap(); - let previous_image = image_file_with_color(31).unwrap(); - for graphic in [false, true] { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); - let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); - - let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); - - assert_eq!(placed["id"], selected["id"]); - if let Some(previous) = previous { - assert_ne!(placed["asset"], previous["asset"]); - } - let document = &session.doc().unwrap().doc; - let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); - assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); - let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; - assert_eq!(json!(content.asset.0), placed["asset"]); - assert_eq!(document.spreads[0].items.len(), 1); - } - } -} - -#[cfg(test)] -mod new_document_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn saved_presets_make_documents_by_name() { - let mut s = Session::new(); - let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277, - "pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50}, - "bleed": [9, 9, 0, 9], "primaryTextFrame": true}); - assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter"); - let listed = s.execute("file.presets", &json!({})).unwrap(); - let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect(); - assert_eq!(saved.len(), 1); - assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters"))); - // By name, case ignored; parameters still override the preset. - s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), 4); - assert!(!d.settings.facing_pages && d.settings.primary_text_frame); - assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters); - assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]); - assert_eq!(d.page(0).unwrap().margins.outside, 50.0); - assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's"); - // The same name replaces it. - s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap(); - assert_eq!(s.prefs.document_presets.len(), 1); - s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.page_count(), 2); - // Built-in names, empty and overlong names and impossible sizes are refused. - for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})] - { - assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}"); - } - s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap(); - assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err()); - assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err()); - } - - #[test] - fn new_document_takes_intent_units_start_page_bleed_and_slug() { - let mut s = Session::new(); - let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); - s.execute("file.new", &p).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); - assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); - assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); - assert_eq!(d.settings.slug, [18.0; 4]); - assert_eq!(d.page_name(0), "4"); - // Malformed edges are ignored, hostile ones bounded. - s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.settings.bleed, [0.0; 4]); - assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); - } - - #[test] - fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { - let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); - let mut s = Session::new(); - s.recovery_dir = Some(dir.clone()); - s.execute("file.new", &json!({})).unwrap(); - let first = s.doc().unwrap().uid; - let a = s.preview_new_document(&json!({"width": 300})).unwrap(); - assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); - let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); - assert_eq!(s.documents().len(), 2, "the preview replaces itself"); - assert!(s.preview_new_document(&json!({"width": 0})).is_err()); - let d = s.doc().unwrap(); - assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); - let listed = s.execute("document.list", &json!({})).unwrap(); - assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); - assert_eq!(s.end_new_document_preview(), Some(b)); - let d = s.doc().unwrap(); - assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); - assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); - // Edited by a script while shown, it writes no recovery data while it is a preview, and - // stays as a document when the window closes, with its Untitled number. - let c = s.preview_new_document(&json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); - // The app's timer saves through the command. - assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0); - assert!(crate::recovery::list(&dir).is_empty()); - assert_eq!(s.end_new_document_preview(), None); - assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); - // Create with the previewed settings keeps the preview as the document. - let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); - assert_eq!(s.keep_new_document_preview(), Some(3)); - let st = s.doc().unwrap(); - assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); - let _ = std::fs::remove_dir_all(&dir); - } -} - #[cfg(test)] pub(crate) mod eps_place_tests; - -#[cfg(test)] -mod pdf_crop_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn place_pdf_cropped_to_its_trim_box() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); - let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - let size = |t: &mut Session, crop: &str| { - t.execute("edit.deselectAll", &json!({})).unwrap(); - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - t.doc().unwrap().doc.item(id).unwrap().bounds() - }; - let full = size(&mut t, "crop"); - let trim = size(&mut t, "trim"); - assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}"); - assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); - assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); - } - - /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, - /// even in a frame larger than the graphic. - #[test] - fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { - // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); - for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { - let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); - } - let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); - - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - // `width` is the whole page's (630 pt with its bleed): half size. - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let d = t.doc().unwrap().doc.clone(); - let it = d.item(id).unwrap(); - let g = it.graphic().unwrap().clone(); - let a = &d.assets[&g.asset]; - assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); - let b = a.shown_box().unwrap(); - let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; - assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); - assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); - let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); - let fb = it.bounds(); - for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { - assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); - } - - // A frame larger than the graphic still shows only the trim box. - t.edit(|d, _| { - d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); - Ok(()) - }) - .unwrap(); - let d = t.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); - let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; - // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows - // where it is on the page. - assert!(!dark(97, 97), "the bleed is outside the trim box"); - assert!(!dark(103, 103), "the page is offset by its bleed"); - assert!(dark(110, 110), "the square inside the trim box"); - } - - #[test] - fn placed_pdf_keeps_its_exact_page_size() { - // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); - let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut t = Session::new(); - t.execute("file.new", &json!({})).unwrap(); - let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); - let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let d = &t.doc().unwrap().doc; - let it = d.item(id).unwrap(); - let b = it.bounds(); - assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); - let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; - assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); - } -} - -#[cfg(test)] -mod layout_place_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn place_a_page_of_an_idml_file_as_a_group() { - let mut a = Session::new(); - a.execute("file.new", &json!({"pages": 2})).unwrap(); - a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); - a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); - let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); - let b64 = super::base64_encode(&idml); - let mut b = Session::new(); - b.execute("file.new", &json!({})).unwrap(); - let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); - assert_eq!(r["items"], 2); - let d = &b.doc().unwrap().doc; - let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); - assert_eq!(g.children().len(), 2, "one group"); - let bb = g.bounds(); - assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); - assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); - assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); - } -} - -#[cfg(test)] -mod save_tests { - use serde_json::json; - - use crate::Session; - - /// Saving over an existing document replaces it whole and leaves nothing beside it. - #[test] - fn save_replaces_the_existing_file_without_leftovers() { - let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); - let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); - assert!(saved.stories.values().any(|st| st.text == "Saved whole")); - let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); - assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); - // A folder that doesn't exist: an error naming the path. - let missing = dir.join("missing").join("doc.designcraft"); - let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); - assert!(e.to_string().contains("missing"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A document that can't be written is an error: the file on disk, the unsaved state and the - /// recovery checkpoint all stay as they were. - #[test] - fn a_save_that_cannot_write_the_document_changes_nothing() { - let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - let recovery = dir.join("recovery"); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - let before = std::fs::read(&path).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap(); - s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap(); - let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid)); - assert!(checkpoint.exists()); - - super::FAIL_TO_BYTES.with(|f| f.set(true)); - let save = s.execute("file.save", &json!({})); - let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})); - let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()})); - let serialized = s.execute("file.serialize", &json!({})); - super::FAIL_TO_BYTES.with(|f| f.set(false)); - - let e = save.unwrap_err(); - assert!(e.to_string().contains("could not write the document"), "{e}"); - assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download"); - assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); - assert!(!dir.join("copy.designcraft").exists()); - assert!(save_as.is_err() && !dir.join("other.designcraft").exists()); - assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file"); - assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes"); - assert!(checkpoint.exists(), "the recovery checkpoint stays"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a - /// document holding a non-finite rect anyway, leaving the file on disk as it was. - #[test] - fn non_finite_coordinates_are_refused_and_never_saved() { - let image = super::image_place_tests::image_file().unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err(); - assert!(e.to_string().contains("finite"), "{e}"); - assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed"); - - let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.designcraft"); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - let before = std::fs::read(&path).unwrap(); - let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap()); - let st = s.doc_mut().unwrap(); - let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0); - std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite); - let e = s.execute("file.save", &json!({})).unwrap_err(); - assert!(e.to_string().contains("not a finite number"), "{e}"); - assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); - assert!(s.doc().unwrap().is_dirty()); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod document_fonts_tests { - use std::path::{Path, PathBuf}; - - use designcraft_fonts::testing::font_with; - use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; - use serde_json::{Value, json}; - - use crate::Session; - - fn temp_dir(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); - dir - } - - /// A new document in `s` with a text frame holding `text` set in `family`. - fn new_document(s: &mut Session, family: &str, text: &str) { - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); - s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); - } - - /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). - fn write_document(path: &Path, family: &str) { - write_document_with(path, family, "Hello"); - } - - fn write_document_with(path: &Path, family: &str, text: &str) { - let mut s = Session::new(); - new_document(&mut s, family, text); - if path.extension().is_some_and(|e| e == "idml") { - std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - } else { - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - } - } - - fn font_entry(s: &mut Session, family: &str) -> Value { - let l = s.execute("font.list", &json!({})).unwrap(); - l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) - } - - /// Each character of the active document's text with the face it is drawn in. - fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { - let st = s.doc().unwrap(); - let mut out = Vec::new(); - for (sid, story) in &st.doc.stories { - let cs = s.cache.get(&st.doc, *sid, None); - for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { - out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); - } - } - out - } - - fn open(s: &mut Session, path: &Path) -> Value { - s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() - } - - #[test] - fn opening_a_document_loads_its_document_fonts() { - const FAMILY: &str = "DocFont Open Test"; - let dir = temp_dir("designcraft"); - let fonts = dir.join(DOCUMENT_FONTS_FOLDER); - std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); - let path = dir.join("Brochure.designcraft"); - write_document(&path, FAMILY); - assert!(!FontDb::global().has_family(FAMILY)); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); - let f = font_entry(&mut s, FAMILY); - assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn opening_an_idml_file_loads_its_document_fonts() { - const FAMILY: &str = "DocFont IDML Test"; - let dir = temp_dir("idml"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); - let path = dir.join("Brochure.idml"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn an_indesign_file_says_how_to_bring_it_over() { - // The extension decides (any case), not the bytes: no InDesign-made file is needed. - let dir = temp_dir("indd"); - let path = dir.join("Brochure.INDD"); - std::fs::write(&path, b"not a DesignCraft document").unwrap(); - let mut s = Session::new(); - let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); - assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); - let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); - assert!(e.contains("InDesign Markup (IDML)"), "{e}"); - assert!(s.doc().is_err(), "nothing was opened"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_document_fonts_folder_is_found_whatever_its_case() { - const FAMILY: &str = "DocFont Case Test"; - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("Document fonts"); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); - let path = dir.join("Brochure.idml"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - let r = open(&mut s, &path); - assert_eq!(r["documentFonts"], 1, "{r}"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_document_without_the_folder_opens_as_before() { - let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("Plain.designcraft"); - write_document(&path, designcraft_fonts::DEFAULT_FAMILY); - let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); - let _ = std::fs::remove_dir_all(&dir); - } - #[test] - fn document_fonts_belong_to_their_document() { - const FAMILY: &str = "DocFont Scoped"; - let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); - std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - write_document(&a.join("A.designcraft"), FAMILY); - write_document(&b.join("B.designcraft"), FAMILY); - - let mut s = Session::new(); - assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); - assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); - // B, open at the same time, doesn't have A's font. - assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); - s.execute("file.activate", &json!({"index": 0})).unwrap(); - assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); - let _ = std::fs::remove_dir_all(&a); - let _ = std::fs::remove_dir_all(&b); - } - - #[test] - fn each_document_sets_its_own_font_of_a_name() { - const FAMILY: &str = "DocFont Same"; - let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); - let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); - // A's font has both letters, B's only `b`. - std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); - std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); - // The same words in both: the word cache must not hand one document's shaping to the other. - write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); - write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); - - let mut s = Session::new(); - open(&mut s, &a.join("A.designcraft")); - open(&mut s, &b.join("B.designcraft")); - // Fallback fonts for the `a` B's font lacks: never A's font. - s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); - let in_b = glyph_faces(&s); - s.execute("file.activate", &json!({"index": 0})).unwrap(); - let in_a = glyph_faces(&s); - assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); - for (c, f) in &in_a { - assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); - } - for (c, f) in &in_b { - match c { - 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), - _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), - } - } - let _ = std::fs::remove_dir_all(&a); - let _ = std::fs::remove_dir_all(&b); - } - - #[test] - fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { - const FAMILY: &str = "DocFont Closed"; - let dir = temp_dir("closed"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Closed.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - let first = glyph_faces(&s)[0].1; - assert_eq!(first.family, FAMILY); - s.execute("file.close", &json!({})).unwrap(); - // A new document can't use the closed document's font. - new_document(&mut s, FAMILY, "Hello"); - assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); - // Reopened, the document has its font again: the face loaded the first time. - assert_eq!(open(&mut s, &path)["documentFonts"], 1); - assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_reverted_document_keeps_its_fonts() { - const FAMILY: &str = "DocFont Reverted"; - let dir = temp_dir("revert"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Revert.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); - s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); - assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn bold_and_italic_link_the_document_fonts_styles() { - const FAMILY: &str = "DocFont Linked"; - let dir = temp_dir("linked"); - std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); - let path = dir.join("Linked.designcraft"); - write_document(&path, FAMILY); - - let mut s = Session::new(); - open(&mut s, &path); - let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - // The family is the document's alone: its Regular is the style without bold or italic. - let r = s.execute("type.bold", &json!({"on": false})).unwrap(); - assert_eq!(r["fontStyle"], "Regular", "{r}"); - let r = s.execute("type.italic", &json!({"on": false})).unwrap(); - assert_eq!(r["fontStyle"], "Regular", "{r}"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(all(test, not(target_arch = "wasm32")))] -mod extension_tests { - use serde_json::json; - - use super::{is_document, with_document_extension}; - use crate::Session; - - #[test] - fn the_document_extension_is_added_when_missing_or_different() { - assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); - assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); - assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); - assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); - assert_eq!(with_document_extension("/a/.."), "/a/.."); - } - - #[test] - fn save_adds_the_extension_and_open_recognises_files_by_content() { - let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - - let bare = dir.join("Brochure"); - let written = dir.join("Brochure.designcraft"); - let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); - assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); - assert!(written.is_file() && !bare.exists()); - assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); - let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); - assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); - - // A native file without the extension opens, and Save writes back to that same file. - std::fs::rename(&written, &bare).unwrap(); - let mut t = Session::new(); - t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); - let r = t.execute("file.save", &json!({})).unwrap(); - assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); - assert!(!written.exists()); - - // An IDML package without the extension opens as IDML, unsaved. - let idml = dir.join("Package"); - std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); - // The start of the file is enough. - for path in [&idml, &bare] { - let bytes = std::fs::read(path).unwrap(); - assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}"); - } - t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); - assert_eq!(t.doc().unwrap().path, None); - - let notes = dir.join("Notes"); - std::fs::write(¬es, b"plain text").unwrap(); - assert!(!is_document(b"plain text")); - let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); - assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); - let _ = std::fs::remove_dir_all(&dir); - } -} - -#[cfg(test)] -mod recent_tests { - use serde_json::json; - - use crate::Session; - - fn temp_dir(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn saved(s: &mut Session, path: &std::path::Path) -> String { - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - path.to_string_lossy().to_string() - } - - /// Opening and saving put a file at the front once; Save a Copy doesn't count. - #[test] - fn open_and_save_order_the_list_without_duplicates() { - let dir = temp_dir("order"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let b = saved(&mut s, &dir.join("b.designcraft")); - assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]); - s.execute("file.open", &json!({"path": a})).unwrap(); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once"); - s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file"); - let rows = s.execute("file.recentFiles", &json!({})).unwrap(); - assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true})); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() { - let dir = temp_dir("open"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let b = saved(&mut s, &dir.join("b.designcraft")); - let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap(); - assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str())); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]); - std::fs::remove_file(&b).unwrap(); - let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); - assert!(e.to_string().contains("can't be found"), "{e}"); - assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back"); - assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false); - // Hostile indices are errors, never panics. - for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] { - assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}"); - } - s.execute("file.clearRecent", &json!({})).unwrap(); - assert!(s.prefs.recent_files.is_empty()); - assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty"); - let _ = std::fs::remove_dir_all(&dir); - } - - /// Saving puts a small picture of the first page in the file, which the start screen reads - /// without opening the document. - #[test] - fn saved_files_carry_a_first_page_preview() { - let dir = temp_dir("preview"); - let mut s = Session::new(); - let a = saved(&mut s, &dir.join("a.designcraft")); - let p = super::recent_preview(&a).expect("a preview"); - assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); - assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); - assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); - let copy = dir.join("copy.designcraft"); - s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); - assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); - // Files without one (saved before previews), missing files and other files have none. - let old = dir.join("old.designcraft"); - std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); - for path in [old, dir.join("gone.designcraft"), dir.clone()] { - assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn the_list_keeps_at_most_the_preferred_count() { - let mut s = Session::new(); - for i in 0..30 { - s.prefs.note_recent_file(&format!("/work/{i}.designcraft")); - } - assert_eq!(s.prefs.recent_files.len(), 20, "default: 20"); - assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft"); - s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap(); - assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]); - assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES); - assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err()); - s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap(); - s.prefs.note_recent_file("/work/new.designcraft"); - assert!(s.prefs.recent_files.is_empty(), "0 keeps no list"); - } - - /// The list is a preference: it survives the round trip through prefs.json. - #[test] - fn the_list_survives_a_restart() { - let mut s = Session::new(); - s.prefs.note_recent_file("/work/a.designcraft"); - let saved = serde_json::to_vec(&s.prefs).unwrap(); - let back: crate::Prefs = serde_json::from_slice(&saved).unwrap(); - assert_eq!(back.recent_files, ["/work/a.designcraft"]); - assert_eq!(back.recent_files_max, 20); - let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap(); - assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); - } -} - -#[cfg(test)] -mod hyphenation_default_tests { - use std::sync::Arc; - - use designcraft_doc::BASIC_PARAGRAPH; - use designcraft_doc::build::NewDocument; - use serde_json::json; - - use crate::Session; - - fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { - let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); - (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) - } - - #[test] - fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); - - let mut d = designcraft_doc::Document::new(&NewDocument::default()); - let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); - st.para.hyph_capitalized = Some(true); - st.para.hyph_last_word = Some(true); - st.para.hyph_across_column = Some(true); - let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); - s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); - assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); - } -} diff --git a/crates/engine/src/cmd/file/bytes.rs b/crates/engine/src/cmd/file/bytes.rs new file mode 100644 index 000000000..0946205a8 --- /dev/null +++ b/crates/engine/src/cmd/file/bytes.rs @@ -0,0 +1,165 @@ +#[cfg(test)] +use super::FAIL_TO_BYTES; +use super::{Document, EngineError, Result}; + +/// The document as `.designcraft` bytes, or an error when it can't be written; never an empty file. +pub fn to_bytes(d: &Document) -> Result> { + save_bytes(d, None) +} + +/// [`to_bytes`] with `preview` (PNG bytes) stored as the file's first-page picture. +fn save_bytes(d: &Document, preview: Option<&[u8]>) -> Result> { + #[cfg(test)] + if FAIL_TO_BYTES.with(std::cell::Cell::get) { + return Err(EngineError::Other("could not write the document: injected failure".into())); + } + designcraft_format::save_with_preview(d, preview).map_err(|e| EngineError::Other(format!("could not write the document: {e}"))) +} + +/// `path` ending in `.designcraft`: the extension is added when it is missing or another one, so +/// the file opens from the file manager. A path without a file name is left for the write to fail. +pub fn with_document_extension(path: &str) -> String { + let p = std::path::Path::new(path); + let ext = p.extension().and_then(std::ffi::OsStr::to_str); + if p.file_name().is_none() || ext.is_some_and(|e| e.eq_ignore_ascii_case(designcraft_format::EXTENSION)) { + return path.to_string(); + } + format!("{path}.{}", designcraft_format::EXTENSION) +} + +/// Whether `bytes` are a document `file.open` reads: a DesignCraft package or an IDML package. +/// The first [`DOCUMENT_SNIFF_BYTES`] of a file are enough for packages that start with their +/// `mimetype` entry, as both formats write them. +pub fn is_document(bytes: &[u8]) -> bool { + designcraft_idml::is_idml(bytes) || designcraft_format::starts_designcraft(bytes) || designcraft_format::is_designcraft(bytes) +} + +/// How much of a file [`is_document`] needs to recognise a package by its `mimetype` entry. +pub const DOCUMENT_SNIFF_BYTES: u64 = 64 << 10; + +/// The longer side of the first-page picture saved in the file, in pixels. +pub(super) const PREVIEW_PX: f64 = 320.0; + +/// [`to_bytes`] with a picture of the first page, which the start screen shows next to the file +/// in its recent files. A page that can't be drawn just leaves the picture out. +pub(super) fn to_bytes_with_preview(d: &Document, cache: &crate::Cache) -> Result> { + let preview = d.page_loc(0).and_then(|(si, pi)| { + let b = d.spreads.get(si)?.pages.get(pi)?.bounds(); + let side = b.width().max(b.height()); + (side.is_finite() && side > 0.0).then_some(PREVIEW_PX / side) + }); + let png = + preview.and_then(|scale| designcraft_render::Renderer::new().render_page(d, cache, 0, scale, false, &Default::default())).map(|r| r.to_png()); + save_bytes(d, png.as_deref()) +} + +pub fn from_bytes(b: &[u8]) -> Result { + load_bytes(b).map(|(d, _)| d) +} + +/// Read `.designcraft` bytes and repair what a hand-edited file can get wrong without failing the +/// open, with a warning for each repair. +pub(super) fn load_bytes(b: &[u8]) -> Result<(Document, Vec)> { + let mut d = designcraft_format::load(b).map_err(|e| EngineError::Other(e.to_string()))?; + let warnings = d.fix_parent_bases(); + Ok((d, warnings)) +} + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn base64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +pub fn base64_decode(s: &str) -> Vec { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut buf = 0u32; + let mut bits = 0; + for b in s.bytes() { + let v = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + _ => continue, + } as u32; + buf = buf << 6 | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buf >> bits) as u8); + } + } + out +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod extension_tests { + use serde_json::json; + + use super::{is_document, with_document_extension}; + use crate::Session; + + #[test] + fn the_document_extension_is_added_when_missing_or_different() { + assert_eq!(with_document_extension("/a/Brochure"), "/a/Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.idml"), "/a/Brochure.idml.designcraft"); + assert_eq!(with_document_extension("/a/v1.2 Brochure"), "/a/v1.2 Brochure.designcraft"); + assert_eq!(with_document_extension("/a/Brochure.DesignCraft"), "/a/Brochure.DesignCraft"); + assert_eq!(with_document_extension("/a/.."), "/a/.."); + } + + #[test] + fn save_adds_the_extension_and_open_recognises_files_by_content() { + let dir = std::env::temp_dir().join(format!("dc-file-extension-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + + let bare = dir.join("Brochure"); + let written = dir.join("Brochure.designcraft"); + let r = s.execute("file.saveAs", &json!({"path": bare.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(written.to_string_lossy()), "{r}"); + assert!(written.is_file() && !bare.exists()); + assert_eq!(s.doc().unwrap().path.as_deref(), written.to_str()); + let r = s.execute("file.saveACopy", &json!({"path": dir.join("Copy").to_string_lossy()})).unwrap(); + assert_eq!(r["path"], json!(dir.join("Copy.designcraft").to_string_lossy()), "{r}"); + + // A native file without the extension opens, and Save writes back to that same file. + std::fs::rename(&written, &bare).unwrap(); + let mut t = Session::new(); + t.execute("file.open", &json!({"path": bare.to_string_lossy()})).unwrap(); + let r = t.execute("file.save", &json!({})).unwrap(); + assert_eq!(r["path"], json!(bare.to_string_lossy()), "{r}"); + assert!(!written.exists()); + + // An IDML package without the extension opens as IDML, unsaved. + let idml = dir.join("Package"); + std::fs::write(&idml, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(is_document(&std::fs::read(&idml).unwrap()) && is_document(&std::fs::read(&bare).unwrap())); + // The start of the file is enough. + for path in [&idml, &bare] { + let bytes = std::fs::read(path).unwrap(); + assert!(is_document(&bytes[..bytes.len().min(128)]), "{path:?}"); + } + t.execute("file.open", &json!({"path": idml.to_string_lossy()})).unwrap(); + assert_eq!(t.doc().unwrap().path, None); + + let notes = dir.join("Notes"); + std::fs::write(¬es, b"plain text").unwrap(); + assert!(!is_document(b"plain text")); + let e = t.execute("file.open", &json!({"path": notes.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("not a DesignCraft or IDML file"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/file/eps_place_tests.rs b/crates/engine/src/cmd/file/eps_place_tests.rs index 9b2c89da9..2501a31d3 100644 --- a/crates/engine/src/cmd/file/eps_place_tests.rs +++ b/crates/engine/src/cmd/file/eps_place_tests.rs @@ -1,9 +1,9 @@ //! Placing EPS artwork: the source is kept and only the preview is rasterised. //! //! These helpers write real files, so they live in their own `_tests.rs` file: the -//! file-system guard in `tests_file_guard.rs` checks `src` files but exempts test ones. +//! file-system guard in `tests/file_guard.rs` checks `src` files but exempts test ones. -use super::*; +use super::{Arc, AssetId, Content, ItemId, Session, base64_encode, json}; pub(crate) fn tiff_eps() -> Vec { let mut tiff = b"II".to_vec(); diff --git a/crates/engine/src/cmd/file/new.rs b/crates/engine/src/cmd/file/new.rs new file mode 100644 index 000000000..6614a2323 --- /dev/null +++ b/crates/engine/src/cmd/file/new.rs @@ -0,0 +1,338 @@ +#[cfg(test)] +use super::bytes::base64_encode; +use super::{Arc, DocState, Document, NewDocument, Result, Session, Value, bad, f64_or, json, str_param}; + +/// The longest saved preset name, and how many presets are kept. +const MAX_PRESET_NAME: usize = 100; + +const MAX_PRESETS: usize = 500; + +/// A built-in document preset or a saved one, by name (case ignored). +fn find_preset(s: &Session, name: &str) -> Option { + NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned()) +} + +/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it. +fn new_document_settings(s: &Session, p: &Value) -> Result { + let mut nd = match str_param(p, "preset") { + Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, + None => NewDocument::default(), + }; + apply_settings(&mut nd, p); + check_size("file.new", &nd)?; + Ok(nd) +} + +/// A document as `file.new` makes it. Its Basic Paragraph doesn't hyphenate capitalized words, a +/// paragraph's last word or across a column. Opened and imported files keep their own values, +/// which is why `ParaProps` keeps the IDML defaults (on). +fn new_document(nd: &NewDocument) -> Document { + let mut d = Document::new(nd); + if let Some(st) = Arc::make_mut(&mut d.styles).para_mut(designcraft_doc::BASIC_PARAGRAPH) { + st.para.hyph_capitalized = Some(false); + st.para.hyph_last_word = Some(false); + st.para.hyph_across_column = Some(false); + } + d +} + +pub(super) fn file_new(s: &mut Session, p: &Value) -> Result { + let mut nd = new_document_settings(s, p)?; + s.untitled += 1; + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); + let d = new_document(&nd); + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i})) +} + +impl Session { + /// New Document's Preview: a document made as `file.new` would make it with `p`, shown while + /// the New Document window is open. It replaces the previous preview document in its tab and + /// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses + /// leave the previous preview as it was. Returns the preview document's uid. + pub fn preview_new_document(&mut self, p: &Value) -> Result { + let mut nd = new_document_settings(self, p)?; + let previous = match self.new_document_preview { + Some((_, previous)) => previous, + None => self.active().map(|d| d.uid), + }; + let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old)); + // A preview edited or saved by a menu or script while the window is open has become a + // document of its own: it stays, and the preview gets a tab of its own. + let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none())); + if replace.is_none() && shown.is_some() { + self.untitled += 1; + } + nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title()); + let st = DocState::new(new_document(&nd), None); + let uid = st.uid; + match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) { + Some((i, d)) => { + *d = st; + self.active = Some(i); + } + None => { + // Pushed without `add_document`: the document that was active stays as it is. + self.docs.push(st); + self.active = Some(self.docs.len() - 1); + } + } + self.new_document_preview = Some((uid, previous)); + Ok(uid) + } + + /// The uid of New Document's preview document, while one is shown. + pub fn new_document_preview(&self) -> Option { + self.new_document_preview.map(|(uid, _)| uid) + } + + /// End New Document's preview. Its document goes, and if it was still the active document, + /// the one that was active before it is active again. A preview that a menu or script has + /// edited or saved stays open as a document, with an Untitled number. Returns the uid of the + /// document removed. Without a preview it does nothing. + pub fn end_new_document_preview(&mut self) -> Option { + let (uid, previous) = self.new_document_preview.take()?; + let i = self.docs.iter().position(|d| d.uid == uid)?; + let d = self.docs.get(i)?; + if d.is_dirty() || d.path.is_some() { + self.untitled += 1; + return None; + } + self.docs.remove(i); + self.active = match self.active { + Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)), + Some(a) if a > i => Some(a - 1), + a => a, + }; + Some(uid) + } + + /// Create in the New Document window, with the settings the preview shows: the preview + /// document becomes the new document, as `file.new` would make it (the next Untitled number, + /// no undo history). Returns its index, or `None` when there is no unedited preview to keep. + pub fn keep_new_document_preview(&mut self) -> Option { + let (uid, _) = self.new_document_preview?; + let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?; + self.new_document_preview = None; + self.untitled += 1; + if let Some(d) = self.docs.get_mut(i) { + d.history.undo.clear(); + d.history.redo.clear(); + } + self.active = Some(i); + Some(i) + } +} + +pub(super) fn save_preset(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string(); + if name.chars().count() > MAX_PRESET_NAME { + return Err(bad("file.savePreset", format!("a preset name has at most {MAX_PRESET_NAME} characters"))); + } + if NewDocument::from_preset(&name).is_some() { + return Err(bad("file.savePreset", format!("`{name}` is a built-in preset"))); + } + let mut nd = NewDocument::default(); + apply_settings(&mut nd, p); + check_size("file.savePreset", &nd)?; + nd.title = name.clone(); + let presets = &mut s.prefs.document_presets; + presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); + if presets.len() >= MAX_PRESETS { + return Err(bad("file.savePreset", format!("at most {MAX_PRESETS} saved presets"))); + } + presets.push(nd); + Ok(json!({"name": name})) +} + +fn check_size(cmd: &str, nd: &NewDocument) -> Result<()> { + if !(nd.width > 0.0 && nd.height > 0.0 && nd.width <= 15552.0 && nd.height <= 15552.0) { + return Err(bad(cmd, "page size out of range (0 < size ≤ 216 in)")); + } + Ok(()) +} + +/// The document settings in `p` (as file.new takes them) over `nd`. +fn apply_settings(nd: &mut NewDocument, p: &Value) { + if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.intent = intent; + } + if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.units = units; + } + nd.width = f64_or(p, "width", nd.width); + nd.height = f64_or(p, "height", nd.height); + nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); + nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); + nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); + nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); + nd.gutter = f64_or(p, "gutter", nd.gutter); + nd.primary_text_frame = p.get("primaryTextFrame").and_then(Value::as_bool).unwrap_or(nd.primary_text_frame); + match p.get("margins") { + Some(Value::Number(n)) => nd.margins = designcraft_doc::Margins::uniform(n.as_f64().unwrap_or(36.0)), + Some(v @ Value::Object(_)) => { + if let Ok(m) = serde_json::from_value(v.clone()) { + nd.margins = m; + } + } + _ => {} + } + if let Some(b) = edges(p, "bleed") { + nd.bleed = b; + } + if let Some(b) = edges(p, "slug") { + nd.slug = b; + } +} + +/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. +fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; + match p.get(key)? { + Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { + [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), + _ => None, + }, + v => v.as_f64().map(|b| [clean(b); 4]), + } +} + +pub(super) fn file_sample(s: &mut Session, _p: &Value) -> Result { + let d = crate::sample::magazine(); + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i})) +} + +#[cfg(test)] +mod new_document_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn saved_presets_make_documents_by_name() { + let mut s = Session::new(); + let newsletter = json!({"name": "Newsletter", "units": "millimeters", "width": 595.2755905511812, "height": 841.8897637795277, + "pages": 8, "facingPages": false, "columns": 3, "margins": {"top": 20, "bottom": 30, "inside": 40, "outside": 50}, + "bleed": [9, 9, 0, 9], "primaryTextFrame": true}); + assert_eq!(s.execute("file.savePreset", &newsletter).unwrap()["name"], "Newsletter"); + let listed = s.execute("file.presets", &json!({})).unwrap(); + let saved: Vec<_> = listed.as_array().unwrap().iter().filter(|p| p["saved"] == true).collect(); + assert_eq!(saved.len(), 1); + assert_eq!((&saved[0]["name"], &saved[0]["units"]), (&json!("Newsletter"), &json!("millimeters"))); + // By name, case ignored; parameters still override the preset. + s.execute("file.new", &json!({"preset": "newsletter", "pages": 4})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), 4); + assert!(!d.settings.facing_pages && d.settings.primary_text_frame); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Millimeters); + assert_eq!(d.settings.bleed, [9.0, 9.0, 0.0, 9.0]); + assert_eq!(d.page(0).unwrap().margins.outside, 50.0); + assert!(d.title.starts_with("Untitled-"), "the preset's name isn't the document's"); + // The same name replaces it. + s.execute("file.savePreset", &json!({"name": "NEWSLETTER", "pages": 2})).unwrap(); + assert_eq!(s.prefs.document_presets.len(), 1); + s.execute("file.new", &json!({"preset": "Newsletter"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_count(), 2); + // Built-in names, empty and overlong names and impossible sizes are refused. + for bad in [json!({"name": "a4"}), json!({"name": " "}), json!({}), json!({"name": "x".repeat(101)}), json!({"name": "Huge", "width": 1e9})] + { + assert!(s.execute("file.savePreset", &bad).is_err(), "{bad}"); + } + s.execute("file.deletePreset", &json!({"name": "newsletter"})).unwrap(); + assert!(s.execute("file.deletePreset", &json!({"name": "newsletter"})).is_err()); + assert!(s.execute("file.new", &json!({"preset": "Newsletter"})).is_err()); + } + + #[test] + fn new_document_takes_intent_units_start_page_bleed_and_slug() { + let mut s = Session::new(); + let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); + s.execute("file.new", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); + assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); + assert_eq!(d.settings.slug, [18.0; 4]); + assert_eq!(d.page_name(0), "4"); + // Malformed edges are ignored, hostile ones bounded. + s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.bleed, [0.0; 4]); + assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); + } + + #[test] + fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() { + let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id())); + let mut s = Session::new(); + s.recovery_dir = Some(dir.clone()); + s.execute("file.new", &json!({})).unwrap(); + let first = s.doc().unwrap().uid; + let a = s.preview_new_document(&json!({"width": 300})).unwrap(); + assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2")); + let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap(); + assert_eq!(s.documents().len(), 2, "the preview replaces itself"); + assert!(s.preview_new_document(&json!({"width": 0})).is_err()); + let d = s.doc().unwrap(); + assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it"); + let listed = s.execute("document.list", &json!({})).unwrap(); + assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true))); + assert_eq!(s.end_new_document_preview(), Some(b)); + let d = s.doc().unwrap(); + assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0)); + assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing"); + // Edited by a script while shown, it writes no recovery data while it is a preview, and + // stays as a document when the window closes, with its Untitled number. + let c = s.preview_new_document(&json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0); + // The app's timer saves through the command. + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"], 0); + assert!(crate::recovery::list(&dir).is_empty()); + assert_eq!(s.end_new_document_preview(), None); + assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2")); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-3"); + // Create with the previewed settings keeps the preview as the document. + let d = s.preview_new_document(&json!({"pages": 2})).unwrap(); + assert_eq!(s.keep_new_document_preview(), Some(3)); + let st = s.doc().unwrap(); + assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None)); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.title, "Untitled-5"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod hyphenation_default_tests { + use std::sync::Arc; + + use designcraft_doc::BASIC_PARAGRAPH; + use designcraft_doc::build::NewDocument; + use serde_json::json; + + use crate::Session; + + fn hyph_flags(d: &designcraft_doc::Document) -> (bool, bool, bool) { + let (pp, _) = d.styles.resolve_para(&designcraft_doc::ParaFormat::default()); + (pp.hyph_capitalized, pp.hyph_last_word, pp.hyph_across_column) + } + + #[test] + fn new_documents_hyphenate_no_capitalized_last_or_column_end_words_and_imports_keep_theirs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (false, false, false)); + + let mut d = designcraft_doc::Document::new(&NewDocument::default()); + let st = Arc::make_mut(&mut d.styles).para_mut(BASIC_PARAGRAPH).unwrap(); + st.para.hyph_capitalized = Some(true); + st.para.hyph_last_word = Some(true); + st.para.hyph_across_column = Some(true); + let b64 = super::base64_encode(&designcraft_idml::export_idml(&d)); + s.execute("file.openIdml", &json!({"base64": b64, "name": "in.idml"})).unwrap(); + assert_eq!(hyph_flags(&s.doc().unwrap().doc), (true, true, true)); + } +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs new file mode 100644 index 000000000..75a7ce627 --- /dev/null +++ b/crates/engine/src/cmd/file/open.rs @@ -0,0 +1,528 @@ +#[cfg(test)] +use super::bytes::{PREVIEW_PX, to_bytes}; +use super::bytes::{base64_decode, load_bytes}; +use super::{Arc, DocState, Document, EngineError, Result, Session, Value, bad, json, str_param}; + +pub(super) fn has_recent(s: &Session) -> std::result::Result<(), String> { + if s.prefs.recent_files.is_empty() { Err("no recent files".into()) } else { Ok(()) } +} + +/// A recent file's first-page picture as straight RGBA, for the start screen. +pub struct RecentPreview { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +/// The picture a `.designcraft` file saved of its first page; None when the file is gone, isn't +/// ours, or was saved before files had one (it gets one when saved again). +pub fn recent_preview(path: &str) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + // Only the zip directory and the preview entry are read, not the whole document. + let png = designcraft_format::read_preview(std::io::BufReader::new(crate::io::open(path).ok()?))?; + let img = designcraft_images::decode_rgba(&png)?; + let (width, height) = img.dimensions(); + Some(RecentPreview { width, height, rgba: img.into_raw() }) + } + #[cfg(target_arch = "wasm32")] + { + let _ = path; + None + } +} + +/// One File › Open Recent row: the path, its file name and whether it is still there. +pub(super) fn recent_entry(path: &str) -> Value { + let name = std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()); + #[cfg(not(target_arch = "wasm32"))] + let exists = crate::io::is_file(path); + #[cfg(target_arch = "wasm32")] + let exists = false; + json!({"path": path, "name": name, "exists": exists}) +} + +pub(super) fn file_open_recent(s: &mut Session, p: &Value) -> Result { + let index = + p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad("file.openRecent", "missing `index`"))?; + let path = s + .prefs + .recent_files + .get(index) + .cloned() + .ok_or_else(|| bad("file.openRecent", format!("no recent file {index} (the list has {})", s.prefs.recent_files.len())))?; + #[cfg(not(target_arch = "wasm32"))] + if !crate::io::is_file(&path) { + return Err(EngineError::Other(format!("{path} can't be found: it was moved, renamed or deleted, or its disk isn't connected"))); + } + file_open(s, &json!({"path": path})) +} + +/// `file.open`: the document opens and moves to the front of File › Open Recent. +pub(super) fn file_open(s: &mut Session, p: &Value) -> Result { + let r = open_path(s, p)?; + if let Some(path) = str_param(p, "path") { + s.prefs.note_recent_file(path); + } + Ok(r) +} + +fn open_path(s: &mut Session, p: &Value) -> Result { + let path = str_param(p, "path").ok_or_else(|| bad("file.open", "missing `path`"))?; + #[cfg(not(target_arch = "wasm32"))] + { + let bytes = crate::io::read_file(path).map_err(EngineError::Other)?; + // The content decides first: a file's extension may be missing or wrong. The extension + // then routes files whose content isn't recognised to the reader that explains why. + let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default().to_ascii_lowercase(); + let native = designcraft_format::is_designcraft(&bytes) || bytes.trim_ascii_start().starts_with(b"{"); + if designcraft_idml::is_idml(&bytes) || (!native && (crate::cmd::interchange::is_indd(path, &bytes) || ext == "idml")) { + return crate::cmd::interchange::open_idml(s, p); + } + if !native && ext != designcraft_format::EXTENSION { + return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file"))); + } + let (mut d, mut warnings) = load_bytes(&bytes)?; + crate::cmd::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path))); + crate::cmd::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path))); + let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path); + warnings.extend(font_warnings); + warnings.extend(crate::cmd::style::grep_style_warnings(&d)); + let mut st = DocState::new(d, Some(path.to_string())); + st.fonts = fonts; + let i = s.add_document(st); + Ok(json!({"index": i, "documentFonts": faces, "warnings": warnings})) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (s, path); + Err(EngineError::Other("use file.openBytes on the web".into())) + } +} + +/// Load the fonts in the `Document Fonts` folder beside the document file at `path` for `d` alone +/// (`d.font_scope`) → (the scope, for the document's state to hold; faces; a warning for each +/// font file skipped). Nothing on the web (no folders). +pub(crate) fn load_document_fonts(d: &mut Document, path: &str) -> (Option>, usize, Vec) { + #[cfg(not(target_arch = "wasm32"))] + { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let Some(folder) = std::path::Path::new(path).parent().map(document_fonts_folder) else { return (None, 0, Vec::new()) }; + // A document's folder is the agent's to name (a recovery entry or a book lists paths too): + // the fonts crate asks the automation roots about the folder and each font file, so fonts + // outside the read root stay unloaded. + let r = designcraft_fonts::FontDb::global().load_document_fonts(&folder, &|p| crate::file_access::check_read(p)); + d.font_scope = r.scope; + let scope = (r.scope != 0).then(|| Arc::new(crate::FontScope(r.scope))); + (scope, r.faces, r.skipped.into_iter().map(|s| format!("{DOCUMENT_FONTS_FOLDER}: {s}")).collect()) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (d, path); + (None, 0, Vec::new()) + } +} + +/// The `Document Fonts` folder in `dir`, matched without regard to case: packages name it +/// `Document fonts`, and file systems on Linux are case-sensitive. The exact name wins. +#[cfg(not(target_arch = "wasm32"))] +fn document_fonts_folder(dir: &std::path::Path) -> std::path::PathBuf { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + let exact = dir.join(DOCUMENT_FONTS_FOLDER); + if crate::io::is_dir(&exact) { + return exact; + } + crate::io::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .find(|e| e.file_name().to_str().is_some_and(|n| n.eq_ignore_ascii_case(DOCUMENT_FONTS_FOLDER)) && crate::io::is_dir(e.path())) + .map_or(exact, |e| e.path()) +} + +pub(super) fn file_open_bytes(s: &mut Session, p: &Value) -> Result { + let b = base64_decode(str_param(p, "base64").unwrap_or("")); + // IDML packages are recognised by their stored `mimetype` first entry, INDD files by their + // signature (or their name). + if designcraft_idml::is_idml(&b) || crate::cmd::interchange::is_indd(str_param(p, "name").unwrap_or_default(), &b) { + return crate::cmd::interchange::open_idml(s, p); + } + let (mut d, warnings) = load_bytes(&b)?; + crate::cmd::datamerge::resolve_sources_on_open(&mut d, None); + if let Some(n) = str_param(p, "name") { + d.title = n.to_string(); + } + let i = s.add_document(DocState::new(d, None)); + Ok(json!({"index": i, "warnings": warnings})) +} + +#[cfg(test)] +mod document_fonts_tests { + use std::path::{Path, PathBuf}; + + use designcraft_fonts::testing::font_with; + use designcraft_fonts::{DOCUMENT_FONTS_FOLDER, FaceRef, FontDb, FontSource}; + use serde_json::{Value, json}; + + use crate::Session; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(DOCUMENT_FONTS_FOLDER)).unwrap(); + dir + } + + /// A new document in `s` with a text frame holding `text` set in `family`. + fn new_document(s: &mut Session, family: &str, text: &str) { + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + + /// A document whose text ("Hello") is set in `family`, written to `path` (`.idml`: as IDML). + fn write_document(path: &Path, family: &str) { + write_document_with(path, family, "Hello"); + } + + fn write_document_with(path: &Path, family: &str, text: &str) { + let mut s = Session::new(); + new_document(&mut s, family, text); + if path.extension().is_some_and(|e| e == "idml") { + std::fs::write(path, designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + } else { + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + } + } + + fn font_entry(s: &mut Session, family: &str) -> Value { + let l = s.execute("font.list", &json!({})).unwrap(); + l.as_array().unwrap().iter().find(|f| f["family"] == family).cloned().unwrap_or_else(|| panic!("{family} in {l}")) + } + + /// Each character of the active document's text with the face it is drawn in. + fn glyph_faces(s: &Session) -> Vec<(char, FaceRef)> { + let st = s.doc().unwrap(); + let mut out = Vec::new(); + for (sid, story) in &st.doc.stories { + let cs = s.cache.get(&st.doc, *sid, None); + for g in cs.frames.iter().flat_map(|ft| &ft.lines).flat_map(|l| &l.glyphs).filter(|g| g.visible && g.len > 0) { + out.push((story.text[g.byte..].chars().next().unwrap(), g.face)); + } + } + out + } + + fn open(s: &mut Session, path: &Path) -> Value { + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap() + } + + #[test] + fn opening_a_document_loads_its_document_fonts() { + const FAMILY: &str = "DocFont Open Test"; + let dir = temp_dir("designcraft"); + let fonts = dir.join(DOCUMENT_FONTS_FOLDER); + std::fs::write(fonts.join("open.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + std::fs::write(fonts.join("broken.otf"), b"not a font").unwrap(); + let path = dir.join("Brochure.designcraft"); + write_document(&path, FAMILY); + assert!(!FontDb::global().has_family(FAMILY)); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert!(r["warnings"].to_string().contains("broken.otf"), "a damaged font is reported, not fatal: {r}"); + let f = font_entry(&mut s, FAMILY); + assert_eq!((f["missing"].clone(), f["source"].clone()), (json!(false), json!("document")), "{f}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn opening_an_idml_file_loads_its_document_fonts() { + const FAMILY: &str = "DocFont IDML Test"; + let dir = temp_dir("idml"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("idml.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_indesign_file_says_how_to_bring_it_over() { + // The extension decides (any case), not the bytes: no InDesign-made file is needed. + let dir = temp_dir("indd"); + let path = dir.join("Brochure.INDD"); + std::fs::write(&path, b"not a DesignCraft document").unwrap(); + let mut s = Session::new(); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)") && !e.contains("expected value"), "{e}"); + let e = s.execute("file.openBytes", &json!({"name": "Brochure.indt", "base64": ""})).unwrap_err().to_string(); + assert!(e.contains("InDesign Markup (IDML)"), "{e}"); + assert!(s.doc().is_err(), "nothing was opened"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_document_fonts_folder_is_found_whatever_its_case() { + const FAMILY: &str = "DocFont Case Test"; + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-case-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("Document fonts"); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("case.ttf"), font_with(FAMILY, &['H']).unwrap()).unwrap(); + let path = dir.join("Brochure.idml"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + let r = open(&mut s, &path); + assert_eq!(r["documentFonts"], 1, "{r}"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], false); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_document_without_the_folder_opens_as_before() { + let dir = std::env::temp_dir().join(format!("dc-open-docfonts-none-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plain.designcraft"); + write_document(&path, designcraft_fonts::DEFAULT_FAMILY); + let r = Session::new().execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["documentFonts"].clone(), r["warnings"].clone()), (json!(0), json!([])), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] + fn document_fonts_belong_to_their_document() { + const FAMILY: &str = "DocFont Scoped"; + let (a, b) = (temp_dir("scoped-a"), temp_dir("scoped-b")); + std::fs::write(a.join(DOCUMENT_FONTS_FOLDER).join("scoped.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + write_document(&a.join("A.designcraft"), FAMILY); + write_document(&b.join("B.designcraft"), FAMILY); + + let mut s = Session::new(); + assert_eq!(open(&mut s, &a.join("A.designcraft"))["documentFonts"], 1); + assert_eq!(open(&mut s, &b.join("B.designcraft"))["documentFonts"], 0); + // B, open at the same time, doesn't have A's font. + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY), "{:?}", glyph_faces(&s)); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn each_document_sets_its_own_font_of_a_name() { + const FAMILY: &str = "DocFont Same"; + let (a, b) = (temp_dir("same-a"), temp_dir("same-b")); + let (a_font, b_font) = (a.join(DOCUMENT_FONTS_FOLDER).join("same.ttf"), b.join(DOCUMENT_FONTS_FOLDER).join("same.ttf")); + // A's font has both letters, B's only `b`. + std::fs::write(&a_font, font_with(FAMILY, &['a', 'b']).unwrap()).unwrap(); + std::fs::write(&b_font, font_with(FAMILY, &['b']).unwrap()).unwrap(); + // The same words in both: the word cache must not hand one document's shaping to the other. + write_document_with(&a.join("A.designcraft"), FAMILY, "ab ab ab"); + write_document_with(&b.join("B.designcraft"), FAMILY, "ab ab ab"); + + let mut s = Session::new(); + open(&mut s, &a.join("A.designcraft")); + open(&mut s, &b.join("B.designcraft")); + // Fallback fonts for the `a` B's font lacks: never A's font. + s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); + let in_b = glyph_faces(&s); + s.execute("file.activate", &json!({"index": 0})).unwrap(); + let in_a = glyph_faces(&s); + assert_eq!((in_a.len(), in_b.len()), (8, 8), "{in_a:?} {in_b:?}"); + for (c, f) in &in_a { + assert_eq!(f.source, FontSource::Document(a_font.clone()), "`{c}` in A"); + } + for (c, f) in &in_b { + match c { + 'a' => assert!(f.family != FAMILY, "B's font lacks `a`, and A's isn't B's: {f:?}"), + _ => assert_eq!(f.source, FontSource::Document(b_font.clone()), "`{c}` in B"), + } + } + let _ = std::fs::remove_dir_all(&a); + let _ = std::fs::remove_dir_all(&b); + } + + #[test] + fn closing_a_document_forgets_its_fonts_and_reopening_reuses_them() { + const FAMILY: &str = "DocFont Closed"; + let dir = temp_dir("closed"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("closed.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Closed.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let first = glyph_faces(&s)[0].1; + assert_eq!(first.family, FAMILY); + s.execute("file.close", &json!({})).unwrap(); + // A new document can't use the closed document's font. + new_document(&mut s, FAMILY, "Hello"); + assert_eq!(font_entry(&mut s, FAMILY)["missing"], true); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family != FAMILY)); + // Reopened, the document has its font again: the face loaded the first time. + assert_eq!(open(&mut s, &path)["documentFonts"], 1); + assert!(glyph_faces(&s).iter().all(|(_, f)| *f == first), "{:?}", glyph_faces(&s)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_reverted_document_keeps_its_fonts() { + const FAMILY: &str = "DocFont Reverted"; + let dir = temp_dir("revert"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("revert.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Revert.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + s.execute("frame.create", &json!({"rect": [72, 400, 200, 500], "content": "text", "text": "x"})).unwrap(); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(font_entry(&mut s, FAMILY)["source"], "document"); + assert!(glyph_faces(&s).iter().all(|(_, f)| f.family == FAMILY)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn bold_and_italic_link_the_document_fonts_styles() { + const FAMILY: &str = "DocFont Linked"; + let dir = temp_dir("linked"); + std::fs::write(dir.join(DOCUMENT_FONTS_FOLDER).join("linked.ttf"), font_with(FAMILY, &['H', 'e', 'l', 'o']).unwrap()).unwrap(); + let path = dir.join("Linked.designcraft"); + write_document(&path, FAMILY); + + let mut s = Session::new(); + open(&mut s, &path); + let sid = *s.doc().unwrap().doc.stories.keys().next().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + // The family is the document's alone: its Regular is the style without bold or italic. + let r = s.execute("type.bold", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let r = s.execute("type.italic", &json!({"on": false})).unwrap(); + assert_eq!(r["fontStyle"], "Regular", "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod recent_tests { + use serde_json::json; + + use crate::Session; + + fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("dc-recent-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn saved(s: &mut Session, path: &std::path::Path) -> String { + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + path.to_string_lossy().to_string() + } + + /// Opening and saving put a file at the front once; Save a Copy doesn't count. + #[test] + fn open_and_save_order_the_list_without_duplicates() { + let dir = temp_dir("order"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let b = saved(&mut s, &dir.join("b.designcraft")); + assert_eq!(s.prefs.recent_files, [b.clone(), a.clone()]); + s.execute("file.open", &json!({"path": a})).unwrap(); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "reopening moves it to the front, once"); + s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a copy isn't a recent file"); + let rows = s.execute("file.recentFiles", &json!({})).unwrap(); + assert_eq!(rows[0], json!({"path": a, "name": "a.designcraft", "exists": true})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn open_recent_reopens_and_a_missing_file_is_an_error_that_keeps_it_listed() { + let dir = temp_dir("open"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let b = saved(&mut s, &dir.join("b.designcraft")); + let r = s.execute("file.openRecent", &json!({"index": 1})).unwrap(); + assert_eq!(s.documents()[r["index"].as_u64().unwrap() as usize].path.as_deref(), Some(a.as_str())); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()]); + std::fs::remove_file(&b).unwrap(); + let e = s.execute("file.openRecent", &json!({"index": 1})).unwrap_err(); + assert!(e.to_string().contains("can't be found"), "{e}"); + assert_eq!(s.prefs.recent_files, [a.clone(), b.clone()], "a disk that isn't connected comes back"); + assert_eq!(s.execute("file.recentFiles", &json!({})).unwrap()[1]["exists"], false); + // Hostile indices are errors, never panics. + for index in [json!(2), json!(u64::MAX), json!(-1), json!("0"), json!(null)] { + assert!(s.execute("file.openRecent", &json!({"index": index})).is_err(), "{index}"); + } + s.execute("file.clearRecent", &json!({})).unwrap(); + assert!(s.prefs.recent_files.is_empty()); + assert!(s.execute("file.openRecent", &json!({"index": 0})).is_err(), "disabled when empty"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Saving puts a small picture of the first page in the file, which the start screen reads + /// without opening the document. + #[test] + fn saved_files_carry_a_first_page_preview() { + let dir = temp_dir("preview"); + let mut s = Session::new(); + let a = saved(&mut s, &dir.join("a.designcraft")); + let p = super::recent_preview(&a).expect("a preview"); + assert_eq!(p.width.max(p.height), super::PREVIEW_PX as u32); + assert_eq!(p.rgba.len(), (p.width * p.height * 4) as usize); + assert!(p.rgba.as_chunks::<4>().0.iter().any(|px| px[3] == 255), "the page is drawn, not left transparent"); + let copy = dir.join("copy.designcraft"); + s.execute("file.saveACopy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert!(super::recent_preview(©.to_string_lossy()).is_some(), "Save a Copy has one too"); + // Files without one (saved before previews), missing files and other files have none. + let old = dir.join("old.designcraft"); + std::fs::write(&old, super::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); + for path in [old, dir.join("gone.designcraft"), dir.clone()] { + assert!(super::recent_preview(&path.to_string_lossy()).is_none(), "{path:?}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_keeps_at_most_the_preferred_count() { + let mut s = Session::new(); + for i in 0..30 { + s.prefs.note_recent_file(&format!("/work/{i}.designcraft")); + } + assert_eq!(s.prefs.recent_files.len(), 20, "default: 20"); + assert_eq!(s.prefs.recent_files[0], "/work/29.designcraft"); + s.execute("prefs.set", &json!({"recentFilesMax": 3})).unwrap(); + assert_eq!(s.prefs.recent_files, ["/work/29.designcraft", "/work/28.designcraft", "/work/27.designcraft"]); + assert_eq!(s.execute("prefs.set", &json!({"recentFilesMax": 4_000_000_000u64})).unwrap()["recentFilesMax"], crate::MAX_RECENT_FILES); + assert!(s.execute("prefs.set", &json!({"recentFilesMax": -1})).is_err()); + s.execute("prefs.set", &json!({"recentFilesMax": 0})).unwrap(); + s.prefs.note_recent_file("/work/new.designcraft"); + assert!(s.prefs.recent_files.is_empty(), "0 keeps no list"); + } + + /// The list is a preference: it survives the round trip through prefs.json. + #[test] + fn the_list_survives_a_restart() { + let mut s = Session::new(); + s.prefs.note_recent_file("/work/a.designcraft"); + let saved = serde_json::to_vec(&s.prefs).unwrap(); + let back: crate::Prefs = serde_json::from_slice(&saved).unwrap(); + assert_eq!(back.recent_files, ["/work/a.designcraft"]); + assert_eq!(back.recent_files_max, 20); + let old: crate::Prefs = serde_json::from_slice(br#"{"recoveryMinutes": 2.0}"#).unwrap(); + assert!(old.recent_files.is_empty(), "older prefs.json without the list still load"); + } +} diff --git a/crates/engine/src/cmd/file/place.rs b/crates/engine/src/cmd/file/place.rs new file mode 100644 index 000000000..44f993a64 --- /dev/null +++ b/crates/engine/src/cmd/file/place.rs @@ -0,0 +1,758 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::EngineError; +#[cfg(test)] +use super::bytes::base64_encode; +use super::bytes::{base64_decode, from_bytes}; +use super::{ + Affine, Arc, Asset, AssetId, Content, Graphic, Item, ItemId, PdfCrop, Rect, Result, Selection, Session, Shape, Value, bad, json, ok, shapes, + str_param, +}; + +pub(super) fn file_place(s: &mut Session, p: &Value) -> Result { + // The file's size and time before it is read (a linked text file remembers them). + let mut before = None; + let (bytes, name, link) = if let Some(b) = str_param(p, "base64") { + (base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None) + } else if let Some(path) = str_param(p, "path") { + before = crate::cmd::datamerge::fingerprint_from_path(path); + #[cfg(not(target_arch = "wasm32"))] + let b = crate::io::read_file(path).map_err(EngineError::Other)?; + #[cfg(target_arch = "wasm32")] + let b: Vec = vec![]; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); + (b, name, Some(path.to_string())) + } else { + s.ui_requests.push(crate::UiRequest::Pick { purpose: "place".into(), params: json!({}) }); + return ok(); + }; + // A page of another layout (IDML or DesignCraft): its objects, as one group. + let lname = name.to_lowercase(); + if lname.ends_with(".idml") || lname.ends_with(".designcraft") { + return place_layout_page(s, p, &name, &bytes); + } + // Keep the EPS source for link comparison; rendering uses its preview at the bounding box size. + let eps_size = eps_size(&bytes, "file.place")?; + // Video and sound: a media frame (Window › Interactive › Media). + if let Some(mime) = designcraft_doc::media_mime(&name) { + return crate::cmd::media::place_media(s, p, name, mime, bytes, link); + } + // Text files (plain, Word, RTF) flow into frames. + if designcraft_textimport::is_text_file(&name) { + return crate::cmd::place_text::place_text(s, p, &name, &bytes, link, before); + } + let want_w = p + .get("width") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_f64() + .filter(|width| width.is_finite() && *width > 0.0) + .ok_or_else(|| bad("file.place", "`width` must be a finite number greater than zero")) + }) + .transpose()?; + // Image Import Options: which page of a PDF (1-based). + if designcraft_render::is_pdf(&bytes) { + designcraft_render::check_pdf_input_size(bytes.len()).map_err(|e| bad("file.place", e.to_string()))?; + } + let pdf_page = match p.get("pdfPage").and_then(Value::as_u64) { + Some(n) if designcraft_render::is_pdf(&bytes) => { + let count = designcraft_render::pdf_page_count(&bytes).unwrap_or(1); + let invalid_page = || bad("file.place", format!("the PDF has {count} page(s)")); + let one_based = usize::try_from(n).map_err(|_| invalid_page())?; + if one_based == 0 || one_based > count { + return Err(invalid_page()); + } + let zero_based = one_based.checked_sub(1).ok_or_else(invalid_page)?; + u32::try_from(zero_based).map_err(|_| invalid_page())? + } + _ => 0, + }; + let (pw, ph) = match pdf_page { + 0 => designcraft_render::image_size(&bytes).ok_or_else(|| bad("file.place", "unsupported or corrupt image"))?, + n => designcraft_render::pdf_page_size(&bytes, n as usize) + .map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32)) + .ok_or_else(|| bad("file.place", "can't read that PDF page"))?, + }; + // 72 ppi by default unless the file says otherwise; scale so it fits the page when huge. PDF pages + // and SVGs keep their exact size: rounding it to whole points would stretch them unevenly. + let (nw, nh) = eps_size.or_else(|| designcraft_render::natural_size(&bytes, pdf_page as usize)).unwrap_or((pw as f64, ph as f64)); + // Image Import Options › Crop to: the graphic spans that box of the PDF page. + let is_pdf = designcraft_render::is_pdf(&bytes); + let pdf_crop = match str_param(p, "pdfCrop") { + Some(k) if is_pdf => PdfCrop::from_name(k) + .ok_or_else(|| bad("file.place", format!("unknown pdfCrop `{k}` (crop, art, trim, bleed, media, contentVisible, contentAll)")))?, + _ => PdfCrop::Crop, + }; + let mime = designcraft_render::image_mime(&bytes).to_string(); + let mut asset = Asset { page: pdf_page, name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), pdf_crop, ..Default::default() }; + // The graphic's natural size: the box in points (the whole page when the box can't be found, + // and then the document says so). + let (bw, bh) = match crop_pdf(&mut asset) { + Some(size) => size, + None => { + asset.pdf_crop = PdfCrop::Crop; + (nw, nh) + } + }; + let target_frame = crate::cmd::id_param(p, "frame").or_else(|| { + if ["x", "y", "spread", "width"].iter().any(|key| p.get(*key).is_some_and(|value| !value.is_null())) { + return None; + } + let st = s.active()?; + st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| matches!(it.content, Content::Unassigned | Content::Graphic(_)))) + }); + // `"spread": null` counts as omitted, like the other placement parameters. + let spread = match p.get("spread") { + Some(Value::Null) => designcraft_doc::SpreadRef::Doc(0), + _ => crate::cmd::spread_param(p, "file.place")?, + }; + let lid = s.doc()?.active_layer; + let px = p.get("x").and_then(Value::as_f64); + let py = p.get("y").and_then(Value::as_f64); + if target_frame.is_none() && px.is_none() && py.is_none() && p.get("loadCursor").and_then(Value::as_bool) == Some(true) { + return load_cursor(s, asset, (bw, bh)); + } + s.edit(|d, sel| { + let fresh = AssetId(d.alloc()); + let aid = d.add_asset(Asset { id: fresh, ..asset }); + // The width the whole page (or image) gets; the height follows the proportions. + let w = match want_w { + Some(w) => w, + None => { + let page_w = d.settings.page_width * 0.6; + nw.min(page_w) + } + }; + let id = if let Some(fid) = target_frame { + let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; + let r = it.inner_bounds(); + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + let k = (r.width() / bw).max(r.height() / bh); + let (gw, gh) = (bw * k, bh * k); + let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k); + validate_placement((bw, bh), r, xf, "file.place")?; + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (bw, bh), + xf, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + fid + } else { + let sp = d.spread(spread).ok_or_else(|| bad("file.place", "no such spread"))?; + let pr = sp.pages.first().map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); + let (x, y) = (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0)); + let id = ItemId(d.alloc()); + let k = w / nw; + let r = crate::cmd::finite_rect("file.place", Rect::new(x, y, x + bw * k, y + bh * k))?; + let xf = Affine::translate((x, y)) * Affine::scale(k); + validate_placement((bw, bh), r, xf, "file.place")?; + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } + it.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (bw, bh), + xf, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(spread, it, None)?; + id + }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0, "asset": aid.0})) + }) +} + +/// Sets `a.pdf_box` to where its `pdf_crop` box sits on its page and returns the box's size in +/// points. The crop box is the whole page and has none; nor has a page that can't be read, which +/// keeps its crop choice (a missing link relinked later gets its box then). +pub(crate) fn crop_pdf(a: &mut Asset) -> Option<(f64, f64)> { + a.pdf_box = None; + if a.pdf_crop == PdfCrop::Crop || !designcraft_render::is_pdf(&a.data) { + return None; + } + let (frac, size) = designcraft_render::pdf_crop_box(&a.data, a.page as usize, a.pdf_crop)?; + a.pdf_box = Some(frac); + Some(size) +} + +pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { + if let Some(b) = str_param(p, "base64") { + return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); + } + let path = str_param(p, "path").ok_or_else(|| bad("place", "missing `path` or `base64`"))?; + #[cfg(not(target_arch = "wasm32"))] + let b = crate::io::read_file(path).map_err(EngineError::Other)?; + #[cfg(target_arch = "wasm32")] + let b: Vec = vec![]; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.into()); + Ok((b, name, Some(path.to_string()))) +} + +fn eps_size(bytes: &[u8], command: &str) -> Result> { + if !designcraft_images::is_eps(bytes) { + return Ok(None); + } + designcraft_images::eps_size(bytes) + .map(Some) + .ok_or_else(|| bad(command, "the EPS has a missing or invalid bounding box (dimensions must be 0.001–20,000 points)")) +} + +/// Fitting can overflow even with finite inputs. Never persist NaN/Inf as JSON nulls. +pub(crate) fn validate_placement(size: (f64, f64), frame: Rect, xf: Affine, command: &str) -> Result<()> { + let valid_rect = |r: Rect| [r.x0, r.y0, r.x1, r.y1, r.width(), r.height()].iter().all(|n| n.is_finite()) && r.width() >= 0.0 && r.height() >= 0.0; + if !size.0.is_finite() + || !size.1.is_finite() + || size.0 <= 0.0 + || size.1 <= 0.0 + || !valid_rect(frame) + || !xf.as_coeffs().iter().all(|n| n.is_finite()) + || !valid_rect(xf.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1))) + { + return Err(bad(command, "image placement exceeds finite geometry limits")); + } + Ok(()) +} + +pub(super) fn place_load(s: &mut Session, p: &Value) -> Result { + let (bytes, name, link) = read_source(p)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad("place.load", "unsupported or corrupt image"))?; + let natural = eps_size(&bytes, "place.load")?.or_else(|| designcraft_render::natural_size(&bytes, 0)).unwrap_or((pw as f64, ph as f64)); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let asset = Asset { name, mime, link, data: Arc::new(bytes), pixels: Some((pw, ph)), ..Default::default() }; + load_cursor(s, asset, natural) +} + +/// Adds `asset` to the document (with a fresh id) and loads it into the place cursor. +fn load_cursor(s: &mut Session, mut asset: Asset, size: (f64, f64)) -> Result { + let name = asset.name.clone(); + let (pw, ph) = asset.pixels.unwrap_or_default(); + let aid = s.edit(|d, _| { + asset.id = AssetId(d.alloc()); + Ok(d.add_asset(asset)) + })?; + s.loaded = Some(crate::LoadedGraphic { asset: aid, size }); + s.set_tool("placeGun"); + Ok(json!({"asset": aid.0, "name": name, "width": pw, "height": ph, "loaded": true})) +} + +pub(super) fn place_drop(s: &mut Session, p: &Value) -> Result { + let crate::LoadedGraphic { asset: aid, size: (nw, nh) } = s.loaded.ok_or_else(|| bad("place.drop", "nothing loaded in the place cursor"))?; + // Undo can remove the loaded graphic's asset. + if !s.doc()?.doc.assets.contains_key(&aid) { + s.loaded = None; + return Err(bad("place.drop", "the loaded graphic is no longer in the document")); + } + let sr = crate::cmd::spread_param(p, "place.drop")?; + let lid = s.doc()?.active_layer; + let frame = crate::cmd::id_param(p, "frame"); + let rect = crate::cmd::rect_param(p, "rect"); + let (x, y) = (crate::cmd::f64_or(p, "x", 0.0), crate::cmd::f64_or(p, "y", 0.0)); + let d = &s.doc()?.doc; + let (frame_rect, xf, auto_fit) = if let Some(fid) = frame { + let r = d.item(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.inner_bounds(); + let k = (r.width() / nw).max(r.height() / nh); + let xf = Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k); + // Fill the frame proportionally once. Auto-Fit stays off, so resizing the frame crops. + (r, xf, Default::default()) + } else { + if d.spread(sr).is_none() { + return Err(bad("place.drop", "no such spread")); + } + // Drag: fit proportionally; click: actual size at the point. + let (r, k) = match rect { + Some(r) if r.width() > 2.0 && r.height() > 2.0 => { + let k = (r.width() / nw).min(r.height() / nh); + (Rect::new(r.x0, r.y0, r.x0 + nw * k, r.y0 + nh * k), k) + } + _ => (Rect::new(x, y, x + nw, y + nh), 1.0), + }; + (r, Affine::translate((r.x0, r.y0)) * Affine::scale(k), Default::default()) + }; + validate_placement((nw, nh), frame_rect, xf, "place.drop")?; + let r = s.edit(|d, sel| { + let graphic = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (nw, nh), + xf, + clip: None, + auto_fit, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let id = if let Some(fid) = frame { + d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?.content = graphic; + fid + } else { + let id = ItemId(d.alloc()); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(frame_rect)); + it.object_style = d.styles.default_graphic_frame.clone(); + if let Some(e) = d.styles.object_effects(&it.object_style) { + it.effects = e.clone(); + } + it.content = graphic; + d.insert_item(sr, it, None)?; + id + }; + *sel = Selection::items(vec![id]); + Ok(json!({"id": id.0})) + })?; + s.loaded = None; + s.set_tool("selection"); + Ok(r) +} + +/// File › Place of an IDML or DesignCraft document: the objects of one page (`layoutPage`, +/// 1-based) grouped, top-left at x/y (default: the source position on the first page). +fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result { + const ID: &str = "file.place"; + let src = if name.to_lowercase().ends_with(".idml") { + crate::cmd::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))? + } else { + from_bytes(bytes)? + }; + let n = src.page_count(); + let page = p.get("layoutPage").and_then(Value::as_u64).unwrap_or(1) as usize; + if page == 0 || page > n { + return Err(bad(ID, format!("the document has {n} page(s)"))); + } + let (si, pi) = src.page_loc(page - 1).ok_or_else(|| bad(ID, "no such page"))?; + let sp = &src.spreads[si]; + let pr = sp.pages[pi].bounds(); + let ids: Vec = sp.items.iter().filter(|it| !it.hidden && sp.page_at_x(it.bounds().center().x) == Some(pi)).map(|it| it.id).collect(); + if ids.is_empty() { + return Err(bad(ID, format!("page {page} has no objects"))); + } + let to = crate::cmd::spread_param(p, ID)?; + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - pr.x0, y - pr.y0), + _ => designcraft_geom::Vec2::new(-pr.x0, -pr.y0), + }; + let placed = s.edit(|d, sel| { + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = crate::cmd::object::duplicate_from(d, &src, &ids, to, off)?; + *sel = Selection::items(new.clone()); + Ok(new) + })?; + if placed.len() > 1 { + let g = s.execute("object.group", &json!({"ids": placed.iter().map(|i| i.0).collect::>()}))?; + return Ok(json!({"id": g["id"], "items": placed.len()})); + } + Ok(json!({"id": placed[0].0, "items": 1})) +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(super) mod image_place_tests { + use designcraft_doc::{Content, ItemId}; + use designcraft_geom::Rect; + use serde_json::{Value, json}; + + use crate::Session; + + pub(in crate::cmd::file) fn image_file() -> std::io::Result { + image_file_with_color(30) + } + + fn image_file_with_color(red: u8) -> std::io::Result { + let file = tempfile::Builder::new().suffix(".png").tempfile()?; + let png = designcraft_render::Rendered { width: 40, height: 20, pixels: [red, 90, 180, 255].repeat(800) }.to_png(); + std::fs::write(file.path(), png)?; + Ok(file) + } + + #[test] + fn consecutive_path_placements_create_distinct_graphics() { + let images = [image_file_with_color(30).unwrap(), image_file_with_color(31).unwrap(), image_file_with_color(32).unwrap()]; + let mut session = Session::new(); + session.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let mut placements = Vec::new(); + let mut originals = Vec::new(); + + for ((spread, x, y), image) in [(0, 0.0, 0.0), (1, 70.0, 90.0), (2, 150.0, 210.0)].into_iter().zip(&images) { + let placed = session.execute("file.place", &json!({"path": image.path(), "spread": spread, "x": x, "y": y})).unwrap(); + let id = ItemId(placed["id"].as_u64().unwrap()); + assert!( + placements.iter().all(|previous: &Value| previous["id"] != placed["id"]), + "each explicit placement needs a distinct frame: {placed}" + ); + let document = &session.doc().unwrap().doc; + let item = document.item(id).unwrap(); + assert_eq!(item.bounds(), Rect::new(x, y, x + 40.0, y + 20.0)); + assert!(document.spreads[spread].items.iter().any(|item| item.id == id)); + originals.push(serde_json::to_value(item).unwrap()); + placements.push(placed); + } + + let document = &session.doc().unwrap().doc; + assert_eq!(document.spreads.iter().flat_map(|spread| &spread.items).filter(|item| matches!(item.content, Content::Graphic(_))).count(), 3); + assert_eq!(document.assets.len(), 3); + for (placed, original) in placements.iter().zip(originals) { + assert_eq!(serde_json::to_value(document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap()).unwrap(), original); + } + let links = session.execute("links.list", &json!({})).unwrap(); + assert_eq!(links.as_array().unwrap().len(), 3); + for (placed, image) in placements.into_iter().zip(&images) { + let row = links.as_array().unwrap().iter().find(|row| row["asset"] == placed["asset"]).unwrap(); + assert_eq!(row["path"], json!(image.path())); + assert_eq!(row["uses"].as_array().unwrap().len(), 1); + assert_eq!(row["uses"][0]["id"], placed["id"]); + } + } + + #[test] + fn individual_placement_parameters_bypass_selected_frames() { + let image = image_file().unwrap(); + for graphic in [false, true] { + for (key, value, expected) in [ + ("x", 0, Rect::new(0.0, 36.0, 40.0, 56.0)), + ("y", 0, Rect::new(36.0, 0.0, 76.0, 20.0)), + ("spread", 0, Rect::new(36.0, 36.0, 76.0, 56.0)), + ("width", 0, Rect::ZERO), + ("width", 80, Rect::new(36.0, 36.0, 116.0, 76.0)), + ] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + if graphic { + session.execute("file.place", &json!({"path": image.path()})).unwrap(); + } + let id = ItemId(selected["id"].as_u64().unwrap()); + let original = serde_json::to_value(session.doc().unwrap().doc.item(id).unwrap()).unwrap(); + let mut params = json!({"path": image.path()}); + params[key] = json!(value); + + let result = session.execute("file.place", ¶ms); + if key == "width" && value == 0 { + assert!(result.is_err(), "zero width must be rejected, graphic={graphic}"); + continue; + } + let placed = result.unwrap(); + + assert_ne!(placed["id"], selected["id"], "{key}={value}, graphic={graphic}"); + let document = &session.doc().unwrap().doc; + assert_eq!(serde_json::to_value(document.item(id).unwrap()).unwrap(), original); + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), expected); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + let [scale_x, _, _, scale_y, _, _] = content.xf.as_coeffs(); + assert!(scale_x > 0.0 && scale_y > 0.0); + } + } + } + + #[test] + fn explicit_frame_wins_over_placement_and_selection() { + let image = image_file().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]})).unwrap(); + + let placed = + session.execute("file.place", &json!({"path": image.path(), "frame": target["id"], "x": 0, "y": 0, "spread": 99, "width": 80})).unwrap(); + + assert_eq!(placed["id"], target["id"]); + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(target["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(item.content, Content::Graphic(_))); + assert!(matches!(document.item(ItemId(selected["id"].as_u64().unwrap())).unwrap().content, Content::Unassigned)); + assert_eq!(document.spreads[0].items.len(), 2); + } + + fn assert_width_rejected(width: Value) -> std::result::Result<(), Box> { + let image = image_file()?; + for graphic in [false, true] { + for explicit_frame in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let target = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + if graphic { + session.execute("file.place", &json!({"path": image.path()}))?; + } + let mut params = json!({"path": image.path(), "width": width}); + if explicit_frame { + params["frame"] = target["id"].clone(); + session.execute("frame.create", &json!({"rect": [300, 320, 400, 420]}))?; + } + let before = session.doc()?; + let document = before.doc.clone(); + let selection = before.selection.clone(); + let revision = before.revision; + let assets = document.assets.len(); + + let result = session.execute("file.place", ¶ms); + + assert!( + matches!(&result, Err(crate::EngineError::BadParams { .. })), + "width={width}, graphic={graphic}, explicit_frame={explicit_frame}: expected BadParams, got {result:?}" + ); + let error = result.err().ok_or_else(|| crate::EngineError::Other("expected placement error".into()))?; + let crate::EngineError::BadParams { cmd, msg } = error else { return Err(error.into()) }; + assert_eq!(cmd, "file.place"); + assert!(msg.contains("width") && msg.contains("finite") && msg.contains("greater than zero"), "{msg}"); + let after = session.doc()?; + assert!(std::sync::Arc::ptr_eq(&document, &after.doc)); + assert_eq!(after.selection, selection); + assert_eq!(after.revision, revision); + assert_eq!(after.doc.assets.len(), assets); + } + } + Ok(()) + } + + #[test] + fn width_rejected_when_zero() { + assert_width_rejected(json!(0)).unwrap(); + } + + #[test] + fn width_rejected_when_negative() { + assert_width_rejected(json!(-80)).unwrap(); + } + + #[test] + fn width_rejected_when_non_numeric() { + for width in [json!("80"), json!(true), json!([80]), json!({"value": 80})] { + assert_width_rejected(width).unwrap(); + } + } + + fn assert_null_placement_uses_selection(keys: &[&str]) -> std::result::Result<(), Box> { + let image = image_file()?; + let previous_image = image_file_with_color(31)?; + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({}))?; + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]}))?; + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()}))).transpose()?; + let mut params = json!({"path": image.path()}); + for key in keys { + params[*key] = Value::Null; + } + + let placed = session.execute("file.place", ¶ms)?; + + assert_eq!(placed["id"], selected["id"], "keys={keys:?}, graphic={graphic}"); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc()?.doc; + let id = ItemId(selected["id"].as_u64().ok_or_else(|| crate::EngineError::Other("frame.create returned no numeric id".into()))?); + let item = document.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + assert!(matches!(&item.content, Content::Graphic(_)), "placed frame must be graphic"); + let Content::Graphic(content) = &item.content else { + return Err(crate::EngineError::Other("placed frame must be graphic".into()).into()); + }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + Ok(()) + } + + #[test] + fn selection_used_when_x_null() { + assert_null_placement_uses_selection(&["x"]).unwrap(); + } + + #[test] + fn selection_used_when_y_null() { + assert_null_placement_uses_selection(&["y"]).unwrap(); + } + + #[test] + fn selection_used_when_spread_null() { + assert_null_placement_uses_selection(&["spread"]).unwrap(); + } + + #[test] + fn selection_used_when_width_null() { + assert_null_placement_uses_selection(&["width"]).unwrap(); + } + + #[test] + fn selection_used_when_all_placement_parameters_null() { + assert_null_placement_uses_selection(&["x", "y", "spread", "width"]).unwrap(); + } + + #[test] + fn implicit_placement_fills_empty_and_replaces_graphic_selection() { + let image = image_file().unwrap(); + let previous_image = image_file_with_color(31).unwrap(); + for graphic in [false, true] { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let selected = session.execute("frame.create", &json!({"rect": [100, 120, 200, 220]})).unwrap(); + let previous = graphic.then(|| session.execute("file.place", &json!({"path": previous_image.path()})).unwrap()); + + let placed = session.execute("file.place", &json!({"path": image.path()})).unwrap(); + + assert_eq!(placed["id"], selected["id"]); + if let Some(previous) = previous { + assert_ne!(placed["asset"], previous["asset"]); + } + let document = &session.doc().unwrap().doc; + let item = document.item(ItemId(placed["id"].as_u64().unwrap())).unwrap(); + assert_eq!(item.bounds(), Rect::new(100.0, 120.0, 200.0, 220.0)); + let Content::Graphic(content) = &item.content else { panic!("placed frame must be graphic") }; + assert_eq!(json!(content.asset.0), placed["asset"]); + assert_eq!(document.spreads[0].items.len(), 1); + } + } +} + +#[cfg(test)] +mod pdf_crop_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_pdf_cropped_to_its_trim_box() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let size = |t: &mut Session, crop: &str| { + t.execute("edit.deselectAll", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": crop, "width": 630, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + t.doc().unwrap().doc.item(id).unwrap().bounds() + }; + let full = size(&mut t, "crop"); + let trim = size(&mut t, "trim"); + assert!((full.width() - 630.0).abs() < 1e-6, "{full:?}"); + assert!((trim.width() - 612.0).abs() < 1e-3 && (trim.height() - 792.0).abs() < 1e-3, "{trim:?}"); + assert!(t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "nope"})).is_err()); + } + + /// Placing with `trim` stores the choice; the graphic spans the trim box and draws only it, + /// even in a frame larger than the graphic. + #[test] + fn a_pdf_placed_with_its_trim_box_stores_and_draws_that_box() { + // Letter with a 9 pt bleed: black in the bleed's top-left corner and a black square inside. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("layout.documentSetup", &json!({"bleed": 9})).unwrap(); + for rect in [[-9, -9, 0, 0], [10, 10, 30, 30]] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "[Black]"})).unwrap(); + } + let b64 = s.execute("file.exportPdf", &json!({"bleed": true})).unwrap()["base64"].as_str().unwrap().to_string(); + + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + // `width` is the whole page's (630 pt with its bleed): half size. + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "pdfCrop": "trim", "width": 315, "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = t.doc().unwrap().doc.clone(); + let it = d.item(id).unwrap(); + let g = it.graphic().unwrap().clone(); + let a = &d.assets[&g.asset]; + assert_eq!(a.pdf_crop, designcraft_doc::PdfCrop::Trim); + let b = a.shown_box().unwrap(); + let want = [9.0 / 630.0, 9.0 / 810.0, 621.0 / 630.0, 801.0 / 810.0]; + assert!(b.iter().zip(want).all(|(x, y)| (x - y).abs() < 1e-4), "{b:?}"); + assert!((g.size.0 - 612.0).abs() < 1e-3 && (g.size.1 - 792.0).abs() < 1e-3, "{:?}", g.size); + let shown = (it.xf * g.xf).transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)); + let fb = it.bounds(); + for (x, y) in [(shown.x0, 100.0), (shown.y0, 100.0), (shown.x1, 406.0), (shown.y1, 496.0), (fb.x1, 406.0), (fb.y1, 496.0)] { + assert!((x - y).abs() < 1e-3, "{shown:?} {fb:?}"); + } + + // A frame larger than the graphic still shows only the trim box. + t.edit(|d, _| { + d.item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(90.0, 90.0, 416.0, 506.0)); + Ok(()) + }) + .unwrap(); + let d = t.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &t.cache, 0, 1.0, false, &Default::default()).unwrap(); + let dark = |x: u32, y: u32| img.pixel(x, y)[0] < 60; + // The page sits at 95.5: its bleed corner (95.5–100) is cut off, the square (105–115) shows + // where it is on the page. + assert!(!dark(97, 97), "the bleed is outside the trim box"); + assert!(!dark(103, 103), "the page is offset by its bleed"); + assert!(dark(110, 110), "the square inside the trim box"); + } + + #[test] + fn placed_pdf_keeps_its_exact_page_size() { + // A 100.4 x 50.4 pt page placed 100.4 pt wide is 50.4 pt tall, not stretched to whole points. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100.4, "height": 50.4, "pages": 1})).unwrap(); + let b64 = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut t = Session::new(); + t.execute("file.new", &json!({})).unwrap(); + let r = t.execute("file.place", &json!({"base64": b64, "name": "a.pdf", "width": 100.4, "x": 0, "y": 0})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = &t.doc().unwrap().doc; + let it = d.item(id).unwrap(); + let b = it.bounds(); + assert!((b.width() - 100.4).abs() < 1e-3 && (b.height() - 50.4).abs() < 1e-3, "{b:?}"); + let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("not a graphic") }; + assert!((g.size.0 - 100.4).abs() < 1e-3 && (g.size.1 - 50.4).abs() < 1e-3, "{:?}", g.size); + } +} + +#[cfg(test)] +mod layout_place_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn place_a_page_of_an_idml_file_as_a_group() { + let mut a = Session::new(); + a.execute("file.new", &json!({"pages": 2})).unwrap(); + a.execute("frame.create", &json!({"rect": [100, 100, 200, 150], "content": "text", "text": "From page one", "caret": false})).unwrap(); + a.execute("frame.create", &json!({"rect": [300, 400, 350, 450]})).unwrap(); + let idml = designcraft_idml::export_idml(&a.doc().unwrap().doc); + let b64 = super::base64_encode(&idml); + let mut b = Session::new(); + b.execute("file.new", &json!({})).unwrap(); + let r = b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 1, "x": 0, "y": 0})).unwrap(); + assert_eq!(r["items"], 2); + let d = &b.doc().unwrap().doc; + let g = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap(); + assert_eq!(g.children().len(), 2, "one group"); + let bb = g.bounds(); + assert!((bb.x0 - 100.0).abs() < 1e-6 && (bb.y0 - 100.0).abs() < 1e-6, "page top-left at 0,0: {bb:?}"); + assert!(d.stories.values().any(|st| st.text == "From page one"), "stories come along"); + assert!(b.execute("file.place", &json!({"base64": b64, "name": "src.idml", "layoutPage": 2})).is_err(), "page 2 is empty"); + } +} diff --git a/crates/engine/src/cmd/file/registry.rs b/crates/engine/src/cmd/file/registry.rs new file mode 100644 index 000000000..216f12bdb --- /dev/null +++ b/crates/engine/src/cmd/file/registry.rs @@ -0,0 +1,141 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::EngineError; +use super::bytes::{base64_encode, to_bytes, to_bytes_with_preview, with_document_extension}; +use super::new::{file_new, file_sample, save_preset}; +use super::open::{file_open, file_open_bytes, file_open_recent, has_recent, recent_entry}; +use super::place::{file_place, place_drop, place_load, read_source}; +use super::save::{FolderAccess, can_revert, file_revert, file_save, recovery_dir}; +use super::snippets::{snippet_export, snippet_place}; +use super::{CommandSpec, PRESETS, Value, always, bad, cmd, has_doc, json, ok, str_param}; + +pub fn specs() -> Vec { + vec![ + cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), + "{preset?: \"Letter\"|\"A4\"|… or a saved preset (file.savePreset), intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?, primaryTextFrame?: bool}", + always, file_new), + cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), + cmd!(query "file.presets", "Document Presets", [], None, "{} → the built-in presets [{name, intent, width, height, units}], then the saved ones (with `saved: true`)", always, |s, _| { + let mut presets: Vec = PRESETS.iter().map(|p| serde_json::to_value(p).unwrap_or_default()).collect(); + presets.extend( + s.prefs + .document_presets + .iter() + .map(|p| json!({"name": p.title, "intent": p.intent, "width": p.width, "height": p.height, "units": p.units, "saved": true})), + ); + Ok(Value::Array(presets)) + }), + cmd!(noundo "file.savePreset", "Save Document Preset", [], None, + "{name, …the settings as file.new} — kept with the preferences; `file.new {preset: name}` uses it. A saved preset of that name is replaced; built-in names are refused", + always, save_preset), + cmd!(noundo "file.deletePreset", "Delete Document Preset", [], None, "{name} — a saved document preset", always, |s, p| { + let name = str_param(p, "name").unwrap_or_default().trim().to_string(); + let before = s.prefs.document_presets.len(); + s.prefs.document_presets.retain(|x| !x.title.eq_ignore_ascii_case(&name)); + if s.prefs.document_presets.len() == before { + return Err(bad("file.deletePreset", format!("no saved preset `{name}`"))); + } + ok() + }), + cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), + "{path} — a DesignCraft or IDML file (recognised by its content, so the extension may be missing), or an InDesign .indd/.indt (recognised by its signature or extension, converted to IDML; its conversion warnings join `warnings`); the fonts in a `Document Fonts` folder beside it load first → {index, documentFonts: faces loaded, warnings: font files skipped, GREP styles whose pattern can't compile, Based on Parent links cleared and, for IDML and InDesign files, package parts missing}", + always, file_open), + cmd!(noundo "file.openBytes", "Open Bytes", [], None, "{name, base64} — DesignCraft JSON, an IDML package or an InDesign .indd/.indt → {index, warnings}", always, file_open_bytes), + cmd!(noundo "file.openRecent", "Open Recent File", [], None, + "{index} — reopen entry `index` of File › Open Recent (as file.open); a file that is gone is an error and stays listed", + has_recent, file_open_recent), + cmd!(query "file.recentFiles", "Recent Files", [], None, "{} → [{path, name, exists}] File › Open Recent, most recent first", always, |s, _| { + Ok(Value::Array(s.prefs.recent_files.iter().map(|p| recent_entry(p)).collect())) + }), + cmd!(noundo "file.clearRecent", "Clear Recent Files", ["File", "Open Recent"], None, "{} — empty File › Open Recent", has_recent, |s, _| { + s.prefs.recent_files.clear(); + ok() + }), + cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} — a `path` without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} — a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, file_save), + cmd!(noundo "file.saveACopy", "Save a Copy…", ["File"], None, "{path} — writes the document without changing which file it is or its unsaved state; a path without the .designcraft extension gets it → {path: the file written, bytes}", has_doc, |s, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.saveACopy", "missing `path`"))?; + let path = with_document_extension(path); + let mut copy = (*s.doc()?.doc).clone(); + crate::cmd::datamerge::refresh_relative_paths(&mut copy, std::path::Path::new(&path)); + let bytes = to_bytes_with_preview(©, &s.cache)?; + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": bytes.len()})) + }), + cmd!(noundo "file.revert", "Revert", ["File"], None, "{} — back to the last saved version (not undoable)", can_revert, file_revert), + cmd!(query "file.recovery.list", "Recovery Data", [], None, "{dir?} → [{uid, path, title, saved}] unsaved documents left by a crash (default: the session's recovery folder)", always, |s, p| { + let dir = recovery_dir(s, p, FolderAccess::Read)?; + Ok(Value::Array(crate::recovery::list(&dir).into_iter().map(|(uid, mut m)| { m["uid"] = json!(uid); m }).collect())) + }), + cmd!(noundo "file.recovery.save", "Save Recovery Data", [], None, "{dir?} — write every unsaved document to the recovery folder (the app does this on a timer)", always, |s, p| { + let dir = recovery_dir(s, p, FolderAccess::Write)?; + Ok(json!({"saved": crate::recovery::save_tracked(s, &dir)?})) + }), + cmd!(noundo "file.recovery.open", "Recover Documents", [], None, "{dir?} — reopen documents left in the recovery folder (unsaved)", always, |s, p| { + // Reopening removes the entries it read. + let dir = recovery_dir(s, p, FolderAccess::ReadWrite)?; + Ok(json!({"opened": crate::recovery::open(s, &dir)?})) + }), + cmd!(query "file.serialize", "Serialize", [], None, "{} → {base64, bytes} the .designcraft file", has_doc, |s, _| { + let st = s.doc()?; + // Preview fill is session state. Serialize the unfilled template. + let doc = st.preview_stash.as_deref().unwrap_or(st.doc.as_ref()); + let bytes = to_bytes(doc)?; + Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})) + }), + cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, |s, p| { + let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).unwrap_or(0); + s.close_document(i); + ok() + }), + cmd!(noundo "file.activate", "Activate Document", [], None, "{index}", has_doc, |s, p| { + s.set_active(p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize); + ok() + }), + cmd!(query "snippet.export", "Export Selection as Snippet", ["File", "Export"], None, + "{path?} — the selected items (with their stories, styles and images) as a .designcraft snippet; no path: {base64}", crate::cmd::has_selection, snippet_export), + cmd!(query "place.styles", "Import Options: Styles", [], None, + "{path | base64, name} → {paragraph: [name], character: [name], conflicts: [name]} — the styles a Word/RTF file brings (for styleMap / styleConflicts)", + has_doc, |s, p| { + let (bytes, name, _) = read_source(p)?; + let imp = designcraft_textimport::import(&name, &bytes).map_err(|e| bad("place.styles", e.to_string()))?; + let st = &s.doc()?.doc.styles; + let conflicts: Vec<&str> = imp + .para_styles + .iter() + .filter(|x| st.para(&x.name).is_some()) + .chain(imp.char_styles.iter().filter(|x| st.char_style(&x.name).is_some())) + .map(|x| x.name.as_str()) + .collect(); + Ok(json!({"paragraph": imp.para_styles.iter().map(|x| &x.name).collect::>(), "character": imp.char_styles.iter().map(|x| &x.name).collect::>(), "conflicts": conflicts})) + }), + cmd!( + "snippet.place", + "Place Snippet", + [], + None, + "{path | base64, spread?, x?, y?} — items keep their positions unless x/y given (top-left)", + has_doc, + snippet_place + ), + cmd!( + "place.load", + "Load Place Cursor", + [], + None, + "{path | base64, name?} — load a graphic into the place cursor (then click/drag with the placeGun tool)", + has_doc, + place_load + ), + cmd!("place.drop", "Place Loaded Graphic", [], None, "{spread?, x, y, rect?: [x0,y0,x1,y1], frame?: id}", has_doc, place_drop), + cmd!( + "file.place", + "Place…", + ["File"], + Some("Cmd+D"), + "{path?|base64?, name?, frame?: id (place into), spread?, x?, y?, width?: finite number > 0 (null: absent), height? (video/audio), pdfPage?: n (1-based, Image Import Options), pdfCrop?: crop|art|trim|bleed|media|contentVisible|contentAll (the box the graphic shows; `width` is the whole page's), loadCursor? (with no frame to fill and no x/y, an image loads into the place cursor, as File › Place), layoutPage?: n (IDML / .designcraft: that page's objects as a group), page?, rect?: [x0,y0,x1,y1] (text: a new frame there), autoflow? (threads one frame per page column from the first frame on, adding pages until the text fits), removeStyles?, styleMap?: {imported name: document style}, styleConflicts?: useExisting|redefine|autoRename, link? (keep the file linked in the Links panel; default: the linkTextFiles preference)} — places an image into `frame` when given; otherwise any explicit x/y/spread/width creates a new frame; otherwise uses the selected empty or graphic frame, or creates a new frame; text files (.txt, .docx, .rtf, .md) and Excel workbooks (.xlsx, as a table) go into the insertion point, the selected frame or a new frame on `page`/`rect`", + has_doc, + file_place + ), + ] +} diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs new file mode 100644 index 000000000..d94a02ab9 --- /dev/null +++ b/crates/engine/src/cmd/file/save.rs @@ -0,0 +1,213 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::DocState; +#[cfg(not(target_arch = "wasm32"))] +use super::bytes::from_bytes; +use super::bytes::{to_bytes_with_preview, with_document_extension}; +#[cfg(not(target_arch = "wasm32"))] +use super::open::load_document_fonts; +#[cfg(test)] +use super::place::image_place_tests; +use super::{Arc, EngineError, Result, Session, Value, bad, json, str_param}; + +pub(super) fn file_save(s: &mut Session, p: &Value) -> Result { + let st = s.doc_mut()?; + // Saving to the document's own file keeps its name, even one opened without the extension. + let path = str_param(p, "path") + .map(with_document_extension) + .or_else(|| st.path.clone()) + .ok_or_else(|| bad("file.save", "missing `path` (document has never been saved)"))?; + // Before anything about the document changes: a refused save leaves it as it was. + crate::file_access::check_write(&path).map_err(EngineError::Other)?; + // Prepare paths for the destination without changing the live document until persistence succeeds. + let mut saved = (*st.doc).clone(); + crate::cmd::datamerge::refresh_relative_paths(&mut saved, std::path::Path::new(&path)); + let paths_changed = saved.data_merge != st.doc.data_merge; + let saved = if paths_changed { Arc::new(saved) } else { st.doc.clone() }; + let bytes = to_bytes_with_preview(&saved, &s.cache)?; + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; + let st = s.doc_mut()?; + if paths_changed { + st.doc = saved; + st.bump_revision(); + } + st.path = Some(path.clone()); + st.saved_revision = st.revision; + st.saved_doc = st.doc.clone(); + #[cfg(not(target_arch = "wasm32"))] + { + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + s.prefs.note_recent_file(&path); + } + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +/// Which access a recovery command needs to the folder it is given. +#[derive(Clone, Copy)] +pub(super) enum FolderAccess { + Read, + Write, + ReadWrite, +} + +/// The recovery folder: `dir` when the command names one, else the session's own (set by the +/// host application, so not the automation roots' business). A named folder is checked against +/// the roots for `access`. +pub(super) fn recovery_dir(s: &Session, p: &Value, access: FolderAccess) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p, access); + Err(EngineError::Other("no recovery folder on the web".into())) + } + #[cfg(not(target_arch = "wasm32"))] + { + let Some(named) = str_param(p, "dir") else { + return s.recovery_dir.clone().ok_or_else(|| bad("file.recovery", "no recovery folder (give `dir`)")); + }; + if matches!(access, FolderAccess::Read | FolderAccess::ReadWrite) { + crate::file_access::check_read(named).map_err(EngineError::Other)?; + } + if matches!(access, FolderAccess::Write | FolderAccess::ReadWrite) { + crate::file_access::check_write(named).map_err(EngineError::Other)?; + } + Ok(std::path::PathBuf::from(named)) + } +} + +pub(super) fn can_revert(s: &Session) -> std::result::Result<(), String> { + match s.active() { + Some(d) if d.path.is_some() && d.is_dirty() => Ok(()), + Some(d) if d.path.is_none() => Err("the document has never been saved".into()), + Some(_) => Err("no changes since the last save".into()), + None => Err("no document open".into()), + } +} + +pub(super) fn file_revert(s: &mut Session, _: &Value) -> Result { + let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; + #[cfg(not(target_arch = "wasm32"))] + { + let mut d = if path.to_ascii_lowercase().ends_with(".idml") { + let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?; + crate::cmd::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))? + } else { + from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)? + }; + // As opened again: the fonts beside it now (unchanged files are shared, not read again). + let (fonts, _, _) = load_document_fonts(&mut d, &path); + let uid = s.doc()?.uid; + let recovery_dir = s.recovery_dir.clone(); + let index = s.active_index().ok_or(EngineError::NoDocument)?; + let mut fresh = DocState::new(d, Some(path.clone())); + fresh.fonts = fonts; + fresh.uid = uid; + // A new state has a revision no state had before, so caches don't show the old document. + crate::recovery::discard_document(s, index, recovery_dir.as_deref()); + *s.doc_mut()? = fresh; + Ok(json!({"path": path})) + } + #[cfg(target_arch = "wasm32")] + { + let _ = (s, path); + Err(EngineError::Other("revert isn't available on the web".into())) + } +} + +#[cfg(test)] +mod save_tests { + use serde_json::json; + + use crate::Session; + use crate::cmd::file::FAIL_TO_BYTES; + + /// Saving over an existing document replaces it whole and leaves nothing beside it. + #[test] + fn save_replaces_the_existing_file_without_leftovers() { + let dir = std::env::temp_dir().join(format!("dc-save-atomic-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + std::fs::write(&path, vec![b'x'; 1 << 20]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Saved whole", "caret": false})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + s.execute("file.saveACopy", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = super::from_bytes(&std::fs::read(&path).unwrap()).unwrap(); + assert!(saved.stories.values().any(|st| st.text == "Saved whole")); + let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert_eq!(names, ["doc.designcraft"], "no temporary file is left"); + // A folder that doesn't exist: an error naming the path. + let missing = dir.join("missing").join("doc.designcraft"); + let e = s.execute("file.save", &json!({"path": missing.to_string_lossy()})).unwrap_err(); + assert!(e.to_string().contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A document that can't be written is an error: the file on disk, the unsaved state and the + /// recovery checkpoint all stay as they were. + #[test] + fn a_save_that_cannot_write_the_document_changes_nothing() { + let dir = std::env::temp_dir().join(format!("dc-save-fails-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + let recovery = dir.join("recovery"); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let before = std::fs::read(&path).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Unsaved", "caret": false})).unwrap(); + s.execute("file.recovery.save", &json!({"dir": recovery.to_string_lossy()})).unwrap(); + let checkpoint = recovery.join(format!("{}.designcraft", s.doc().unwrap().uid)); + assert!(checkpoint.exists()); + + FAIL_TO_BYTES.with(|f| f.set(true)); + let save = s.execute("file.save", &json!({})); + let copy = s.execute("file.saveACopy", &json!({"path": dir.join("copy.designcraft").to_string_lossy()})); + let save_as = s.execute("file.saveAs", &json!({"path": dir.join("other.designcraft").to_string_lossy()})); + let serialized = s.execute("file.serialize", &json!({})); + FAIL_TO_BYTES.with(|f| f.set(false)); + + let e = save.unwrap_err(); + assert!(e.to_string().contains("could not write the document"), "{e}"); + assert!(copy.is_err() && serialized.is_err(), "no empty copy and no empty download"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); + assert!(!dir.join("copy.designcraft").exists()); + assert!(save_as.is_err() && !dir.join("other.designcraft").exists()); + assert!(!s.prefs.recent_files.iter().any(|p| p.ends_with("other.designcraft")), "a failed Save As isn't a recent file"); + assert!(s.doc().unwrap().is_dirty(), "the document still has unsaved changes"); + assert!(checkpoint.exists(), "the recovery checkpoint stays"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #1050: a placement whose coordinates overflow to infinity is refused, and Save refuses a + /// document holding a non-finite rect anyway, leaving the file on disk as it was. + #[test] + fn non_finite_coordinates_are_refused_and_never_saved() { + let image = super::image_place_tests::image_file().unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let e = s.execute("file.place", &json!({"path": image.path(), "x": 1e308, "width": 1e308})).unwrap_err(); + assert!(e.to_string().contains("finite"), "{e}"); + assert!(s.doc().unwrap().doc.all_items().is_empty(), "nothing is placed"); + + let dir = std::env::temp_dir().join(format!("dc-save-non-finite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.designcraft"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let before = std::fs::read(&path).unwrap(); + let id = designcraft_doc::ItemId(s.execute("frame.create", &json!({"rect": [36, 36, 300, 200]})).unwrap()["id"].as_u64().unwrap()); + let st = s.doc_mut().unwrap(); + let infinite = designcraft_geom::Rect::new(36.0, 36.0, f64::INFINITY, 200.0); + std::sync::Arc::make_mut(&mut st.doc).item_mut(id).unwrap().path = designcraft_geom::shapes::rectangle(infinite); + let e = s.execute("file.save", &json!({})).unwrap_err(); + assert!(e.to_string().contains("not a finite number"), "{e}"); + assert_eq!(std::fs::read(&path).unwrap(), before, "the saved file is untouched"); + assert!(s.doc().unwrap().is_dirty()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/file/snippets.rs b/crates/engine/src/cmd/file/snippets.rs new file mode 100644 index 000000000..5ff1f3795 --- /dev/null +++ b/crates/engine/src/cmd/file/snippets.rs @@ -0,0 +1,56 @@ +#[cfg(not(target_arch = "wasm32"))] +use super::EngineError; +use super::bytes::{base64_encode, from_bytes, to_bytes}; +use super::place::read_source; +use super::{ItemId, Rect, Result, Selection, Session, Value, bad, json, str_param}; + +/// The selection as `.designcraft` snippet bytes. +pub(crate) fn snippet_bytes(s: &mut Session) -> Result> { + to_bytes(&crate::cmd::edit::clip_doc(s)?) +} + +pub(super) fn snippet_export(s: &mut Session, p: &Value) -> Result { + let d = crate::cmd::edit::clip_doc(s)?; + let bytes = to_bytes(&d)?; + match str_param(p, "path") { + Some(path) => { + #[cfg(not(target_arch = "wasm32"))] + crate::io::write_file(path, &bytes).map_err(EngineError::Other)?; + Ok(json!({"path": path, "bytes": bytes.len(), "items": d.spreads.first().map(|s| s.items.len()).unwrap_or(0)})) + } + None => Ok(json!({"base64": base64_encode(&bytes), "bytes": bytes.len()})), + } +} + +pub(crate) fn snippet_place(s: &mut Session, p: &Value) -> Result { + let (bytes, _, _) = read_source(p)?; + let src = from_bytes(&bytes)?; + let ids: Vec = src.spreads.first().map(|sp| sp.items.iter().map(|i| i.id).collect()).unwrap_or_default(); + if ids.is_empty() { + return Err(bad("snippet.place", "the snippet is empty")); + } + let sr = crate::cmd::spread_param(p, "snippet.place")?; + let bounds = ids.iter().filter_map(|i| src.item(*i).map(|it| it.bounds())).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO); + let off = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { + (Some(x), Some(y)) => designcraft_geom::Vec2::new(x - bounds.x0, y - bounds.y0), + _ => designcraft_geom::Vec2::ZERO, + }; + s.edit(|d, sel| { + // Bring over styles and swatches the snippet uses that this document lacks. + for sw in &src.swatches { + if d.swatch(&sw.name).is_none() { + d.swatches.push(sw.clone()); + } + } + let missing_p: Vec<_> = src.styles.paragraph.iter().filter(|ps| d.styles.para(&ps.name).is_none()).cloned().collect(); + let missing_c: Vec<_> = src.styles.character.iter().filter(|cs| d.styles.char_style(&cs.name).is_none()).cloned().collect(); + if !missing_p.is_empty() || !missing_c.is_empty() { + let st = d.styles_mut(); + st.paragraph.extend(missing_p); + st.character.extend(missing_c); + } + let new = crate::cmd::object::duplicate_from(d, &src, &ids, sr, off)?; + *sel = Selection::items(new.clone()); + Ok(json!({"ids": new.iter().map(|i| i.0).collect::>()})) + }) +} diff --git a/crates/engine/src/cmd/file_info.rs b/crates/engine/src/cmd/file_info.rs new file mode 100644 index 000000000..bc6f88ba6 --- /dev/null +++ b/crates/engine/src/cmd/file_info.rs @@ -0,0 +1,201 @@ +//! File › File Info: the document's title, author, description, keywords and copyright. PDF +//! export writes the title, author, description and keywords into the file's metadata, and EPUB +//! export writes the title and author. + +use designcraft_doc::FileInfo; +use designcraft_doc::file_info::{clean_keywords, parse_keywords}; +use serde_json::{Map, Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc}; +use crate::{Result, Session}; + +const ID: &str = "file.info"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.info", + "File Info…", + ["File"], + None, + "{documentTitle?, author?, description?, keywords?: [\"a\", \"b\"] | \"a; b\", copyrightNotice?, copyrightInfoUrl?}: each field given replaces the stored one (\"\" or null clears it) → the file info", + has_doc, + file_info + ), + cmd!(query "file.info.get", "File Info", [], None, "{} → the file info, without changing the document", has_doc, |s, _| Ok(to_json( + &s.doc()?.doc.file_info + ))), + ] +} + +fn file_info(s: &mut Session, p: &Value) -> Result { + let cur = s.doc()?.doc.file_info.clone(); + let o = match p { + Value::Null => return Ok(to_json(&cur)), + Value::Object(o) if o.is_empty() => return Ok(to_json(&cur)), + Value::Object(o) => o, + _ => return Err(bad(ID, "the parameters must be an object of fields")), + }; + let next = apply(&cur, o)?; + if next == cur { + return Ok(to_json(&cur)); + } + s.edit(|d, _| { + d.file_info = next; + Ok(to_json(&d.file_info)) + }) +} + +/// `cur` with the fields of `o` applied. +fn apply(cur: &FileInfo, o: &Map) -> Result { + let mut f = cur.clone(); + for (key, v) in o { + let field = match key.as_str() { + "documentTitle" => &mut f.document_title, + "author" => &mut f.author, + "description" => &mut f.description, + "copyrightNotice" => &mut f.copyright_notice, + "copyrightInfoUrl" => &mut f.copyright_info_url, + "keywords" => { + f.keywords = match v { + Value::Null => Vec::new(), + Value::String(t) => parse_keywords(t), + Value::Array(a) => { + let words: Option> = a.iter().map(Value::as_str).collect(); + clean_keywords(words.ok_or_else(|| bad(ID, "`keywords` must be strings"))?) + } + _ => return Err(bad(ID, "`keywords` must be a list of strings or one string")), + }; + continue; + } + other => return Err(bad(ID, format!("unknown field `{other}`"))), + }; + *field = match v { + Value::Null => String::new(), + Value::String(t) => t.trim().to_string(), + _ => return Err(bad(ID, format!("`{key}` must be a string"))), + }; + } + Ok(f) +} + +fn to_json(f: &FileInfo) -> Value { + json!({ + "documentTitle": f.document_title, + "author": f.author, + "description": f.description, + "keywords": f.keywords, + "copyrightNotice": f.copyright_notice, + "copyrightInfoUrl": f.copyright_info_url, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "title": "flyer-v3"})).unwrap(); + s + } + + #[test] + fn file_info_sets_reads_and_clears_the_fields() { + let mut s = session(); + let empty = s.execute("file.info", &json!({})).unwrap(); + assert_eq!(empty["documentTitle"], ""); + assert_eq!(empty["keywords"], json!([])); + let r = s + .execute( + "file.info", + &json!({"documentTitle": " Spring Catalog ", "author": "Ana Ruiz", "description": "Shoes", "keywords": "spring; shoes, spring", + "copyrightNotice": "© 2026 Ana Ruiz", "copyrightInfoUrl": "https://example.com/rights"}), + ) + .unwrap(); + assert_eq!(r["documentTitle"], "Spring Catalog"); + assert_eq!(r["keywords"], json!(["spring", "shoes"])); + assert_eq!(s.execute("file.info", &json!({})).unwrap(), r, "a read returns what was set"); + let info = &s.doc().unwrap().doc.file_info; + assert_eq!((info.author.as_str(), info.copyright_info_url.as_str()), ("Ana Ruiz", "https://example.com/rights")); + assert_eq!(s.doc().unwrap().doc.title, "flyer-v3", "the document's name stays"); + // Only the fields given change; "" and null clear one. + let r = s.execute("file.info", &json!({"author": "", "keywords": [" catalog ", ""], "description": null})).unwrap(); + assert_eq!((r["author"].as_str(), r["description"].as_str(), r["documentTitle"].as_str()), (Some(""), Some(""), Some("Spring Catalog"))); + assert_eq!(r["keywords"], json!(["catalog"])); + } + + #[test] + fn file_info_is_one_undo_step_and_survives_a_save() { + let mut s = session(); + let steps = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = steps(&s); + s.execute("file.info", &json!({"documentTitle": "Spring Catalog", "keywords": ["spring"]})).unwrap(); + assert_eq!(steps(&s), before + 1); + s.execute("file.info", &json!({"documentTitle": "Spring Catalog"})).unwrap(); + s.execute("file.info", &json!({})).unwrap(); + assert_eq!(steps(&s), before + 1, "no change, no undo step"); + let saved = s.execute("file.serialize", &json!({})).unwrap(); + let mut t = Session::new(); + t.execute("file.openBytes", &json!({"name": "a.designcraft", "base64": saved["base64"]})).unwrap(); + assert_eq!(t.doc().unwrap().doc.file_info.document_title, "Spring Catalog", "saved with the document"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.file_info.is_empty()); + } + + #[test] + fn file_info_get_leaves_a_data_merge_preview_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": ""})).unwrap(); + let story = frame["story"].as_u64().unwrap(); + s.execute("data.source.select", &json!({"bytes": crate::cmd::base64_encode(b"x\nAda\n"), "name": "people.csv"})).unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": story, "at": 0})).unwrap(); + s.execute("file.info", &json!({"author": "Ana Ruiz"})).unwrap(); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let r = s.execute("file.info.get", &json!({})).unwrap(); + assert_eq!(r["author"], "Ana Ruiz"); + assert!(s.doc().unwrap().preview_stash.is_some(), "the preview stays on"); + s.execute("file.info", &json!({})).unwrap(); + assert!(s.doc().unwrap().preview_stash.is_none(), "file.info is an edit, so it ends the preview"); + } + + #[test] + fn file_info_refuses_bad_fields() { + let mut s = session(); + for p in [json!({"title": "x"}), json!({"author": 3}), json!({"keywords": [1]}), json!({"keywords": {"a": 1}})] { + assert!(s.execute("file.info", &p).is_err(), "{p}"); + } + assert!(s.doc().unwrap().doc.file_info.is_empty(), "a refused call changes nothing"); + // Parameters that are not an object are refused; null reads like {}. + s.execute("file.info", &json!({"author": "Ana Ruiz"})).unwrap(); + for p in [json!(3), json!("author"), json!(true), json!(["author"])] { + let e = s.execute("file.info", &p).unwrap_err().to_string(); + assert!(e.contains("must be an object"), "{p}: {e}"); + } + assert_eq!(s.execute("file.info", &serde_json::Value::Null).unwrap()["author"], "Ana Ruiz"); + } + + #[test] + fn pdf_and_epub_export_take_the_file_info() { + let mut s = session(); + s.execute("file.info", &json!({"documentTitle": "Spring Catalog", "author": "Ana Ruiz", "keywords": ["spring", "shoes"]})).unwrap(); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + let pdf = crate::cmd::base64_decode(r["base64"].as_str().unwrap()); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/Title(Spring Catalog)") && text.contains("/Author(Ana Ruiz)"), "PDF metadata"); + let r = s.execute("file.exportPdf", &json!({"title": "Proof"})).unwrap(); + let pdf = crate::cmd::base64_decode(r["base64"].as_str().unwrap()); + assert!(String::from_utf8_lossy(&pdf).contains("/Title(Proof)"), "a title given to the export wins"); + for (cmd, opf_path) in [("file.exportEpub", "OEBPS/content.opf"), ("file.exportFixedEpub", "OEBPS/content.opf")] { + let r = s.execute(cmd, &json!({})).unwrap(); + let epub = crate::cmd::base64_decode(r["base64"].as_str().unwrap()); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(epub)).unwrap(); + let mut opf = String::new(); + std::io::Read::read_to_string(&mut z.by_name(opf_path).unwrap(), &mut opf).unwrap(); + assert!(opf.contains("Spring Catalog") && opf.contains("Ana Ruiz"), "{cmd}: {opf}"); + } + } +} diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index a40e8da51..8d7c68632 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -323,18 +323,38 @@ fn replacement(p: &Value) -> String { if bool_or(p, "grep", false) { translate_replacement(c) } else { metachars(c) } } +/// Repair byte validity of a text selection once against the final story, without remapping valid offsets. +fn repair_selection(t: &mut designcraft_doc::TextSel, st: &designcraft_doc::Story) { + let range = t.range(); + let start = floor_char_boundary(&st.text, range.start); + let mut end = range.end.min(st.len()); + if t.is_caret() { + end = start; + } else if !st.text.is_char_boundary(end) { + let floor = floor_char_boundary(&st.text, end); + end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); + } + (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; +} + +/// The character overrides a Change applies to every replacement, from its `attrs` object. +fn char_overrides(attrs: Option<&Value>) -> Result { + let mut over = designcraft_doc::CharAttrs::default(); + if let Some(o) = attrs.and_then(Value::as_object) { + for (k, v) in o { + over.set_json(k, v).map_err(|e| bad("find.change", e))?; + } + } + Ok(over) +} + fn change(s: &mut Session, p: &Value) -> Result { let re = build(p)?; let grep = bool_or(p, "grep", false); let rep = replacement(p); let first_only = bool_or(p, "first", false); let attrs = p.get("attrs").cloned(); - let mut over = designcraft_doc::CharAttrs::default(); - if let Some(o) = attrs.as_ref().and_then(Value::as_object) { - for (k, v) in o { - over.set_json(k, v).map_err(|e| bad("find.change", e))?; - } - } + let over = char_overrides(attrs.as_ref())?; super::text::validate_char_fill(&s.doc()?.doc, &over, "find.change")?; let targets = scope(s, p)?.targets; s.edit(|d, sel| { @@ -361,17 +381,7 @@ fn change(s: &mut Session, p: &Value) -> Result { count += 1; } if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == cell) { - // Repair byte validity once against the final story, without remapping valid offsets. - let range = t.range(); - let start = floor_char_boundary(&st.text, range.start); - let mut end = range.end.min(st.len()); - if t.is_caret() { - end = start; - } else if !st.text.is_char_boundary(end) { - let floor = floor_char_boundary(&st.text, end); - end = st.text.get(floor..).and_then(|rest| rest.chars().next()).map_or(floor, |ch| floor + ch.len_utf8()); - } - (t.anchor, t.focus) = if t.anchor <= t.focus { (start, end) } else { (end, start) }; + repair_selection(t, st); } // `first` is the first match of the whole search, not of each story. if first_only && count > 0 { @@ -431,757 +441,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn find_and_change_text_and_grep() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Call 555-1234 or 555-9876.\nColour and colour."})) - .unwrap(); - let m = s.execute("find.find", &json!({"find": "colour"})).unwrap(); - assert_eq!(m.as_array().unwrap().len(), 2); - let m = s.execute("find.find", &json!({"find": "colour", "caseSensitive": true})).unwrap(); - assert_eq!(m.as_array().unwrap().len(), 1); - let r = s.execute("find.change", &json!({"find": r"(\d{3})-(\d{4})", "change": "($1) $2", "grep": true})).unwrap(); - assert_eq!(r["count"], 2); - let r = s.execute("find.change", &json!({"find": "colour", "change": "color", "attrs": {"fontStyle": "Italic"}})).unwrap(); - assert_eq!(r["count"], 2); - let d = s.execute("document.inspect", &json!({})).unwrap(); - let sid = d["stories"][0]["id"].as_u64().unwrap(); - let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); - assert_eq!(text, "Call (555) 1234 or (555) 9876.\ncolor and color."); - // Paragraph metacharacter in text mode. - assert_eq!(s.execute("find.find", &json!({"find": ".^pc"})).unwrap().as_array().unwrap().len(), 1); - s.execute("find.next", &json!({"find": "or"})).unwrap(); - assert!(s.doc().unwrap().selection.text.is_some()); - } - - #[test] - fn change_first_changes_one_match_in_the_whole_document() { - for grep in [false, true] { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for y in [36, 336] { - s.execute("frame.create", &json!({"rect": [36, y, 300, y + 200], "content": "text", "text": "red and red"})).unwrap(); - } - let r = s.execute("find.change", &json!({"find": "red", "change": "blue", "first": true, "grep": grep})).unwrap(); - assert_eq!(r["count"], 1); - assert_eq!(s.execute("find.find", &json!({"find": "blue"})).unwrap().as_array().unwrap().len(), 1); - assert_eq!(s.execute("find.find", &json!({"find": "red"})).unwrap().as_array().unwrap().len(), 3); - } - } - - fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); - (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) - } - - fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { - s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); - } - - fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { - assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); - } - - #[test] - fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { - for (texts, first, explicit, grep, expected, count) in [ - (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), - (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), - (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), - (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), - (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), - (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), - (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), - (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), - (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), - ] { - let (mut s, sid) = scope_session(texts[0]); - let mut sids = vec![sid]; - for text in texts.iter().skip(1) { - let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); - sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); - } - assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); - // Document Change keeps canonical target order even with a later story current. - scope_select(&mut s, *sids.last().unwrap(), 0, 0); - let before = s.doc().unwrap().doc.clone(); - let mut p = json!({ - "find": if grep { "(?:)" } else { "red" }, - "change": if grep { "X" } else { "blue" }, - "scope": "document", "grep": grep - }); - if let Some(first) = first { - p["first"] = json!(first); - } - if explicit { - p["story"] = json!(sid.0); - p["scope"] = json!("selection"); - } - if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { - let last = *sids.last().unwrap(); - scope_hit(&mut s, &p, last, 0, 3); - let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; - assert_eq!( - s.execute("find.find", &p).unwrap(), - json!([ - {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, - {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, - {"story": last.0, "start": 0, "end": 3, "text": "red"} - ]), - "Count remains canonical after Next paints the later story" - ); - } - let selection = s.doc().unwrap().selection.clone(); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); - let st = s.doc().unwrap(); - assert_eq!(st.selection, selection); - for (i, sid) in sids.iter().enumerate() { - assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); - if texts[i] == expected[i] { - assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); - } - } - st.doc.check().unwrap(); - } - - // Identical replacement is still one counted match, not a reason to visit another story. - let (mut s, sid) = scope_session("red red"); - let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); - let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, other_sid, 0, 3); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - assert_eq!( - s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), - json!({"count": 1}) - ); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); - assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); - assert_eq!(s.doc().unwrap().selection, selection); - s.doc().unwrap().doc.check().unwrap(); - - for reverse in [false, true] { - let (mut s, sid) = scope_session("éX X"); - let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); - let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); - let before = s.doc().unwrap().doc.clone(); - let mut selection = s.doc().unwrap().selection.clone(); - let t = selection.text.as_mut().unwrap(); - (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; - let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); - assert_eq!(st.doc.story(other_sid), before.story(other_sid)); - assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); - st.doc.check().unwrap(); - assert_eq!( - s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), - json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) - ); - } - } - - #[test] - fn grep_in_the_documents_dialect() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Москва and Paris, 1\u{2013}2 days.\nok"})).unwrap(); - // `\u` uppercase, `\l` lowercase letter. - let m = s.execute("find.find", &json!({"find": r"\u\l+", "grep": true, "caseSensitive": true})).unwrap(); - let words: Vec<&str> = m.as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); - assert_eq!(words, ["Москва", "Paris"]); - // `~=` en dash, `\h` space, `\r` paragraph end; the change uses groups, `~S` and `\r`. - let r = s.execute("find.change", &json!({"find": r"(\d)~=(\d)\h(\l+)\.\r", "change": r"$1~=$2~S$3!\r", "grep": true})).unwrap(); - assert_eq!(r["count"], 1); - let sid = s.execute("document.inspect", &json!({})).unwrap()["stories"][0]["id"].as_u64().unwrap(); - let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(text.starts_with("Москва and Paris, 1\u{2013}2\u{A0}days!\nok"), "{text:?}"); - // A pattern that doesn't compile is an error, not a silent no-op. - assert!(s.execute("find.find", &json!({"find": "[[:nope:]]", "grep": true})).is_err()); - assert!(s.execute("find.change", &json!({"find": "(", "change": "", "grep": true})).is_err()); - } - - #[test] - fn find_scope_exact_baseline_count_next_change() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let frame = s - .execute( - "frame.create", - &json!({ - "rect": [36, 36, 300, 200], "content": "text", "text": "red red" - }), - ) - .unwrap(); - let sid = frame["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - assert_eq!( - s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), - json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) - ); - assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); - assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); - assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); - } - - #[test] - fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { - for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { - for reverse in [false, true] { - let (mut s, sid) = scope_session(text); - // Real caret movement keeps the frame identity, unlike text.select. - s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); - if !reverse { - s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); - } - let selected = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); - assert!(selected.frame.is_some()); - let mut expected = s.doc().unwrap().selection.clone(); - let t = expected.text.as_mut().unwrap(); - (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; - let p = json!({"find": find, "change": change, "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().text, changed); - assert_eq!(st.selection, expected); - let t = st.selection.text.unwrap(); - assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); - assert!(s.find_context.is_none()); - let p = json!({"find": change, "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); - scope_hit(&mut s, &p, sid, start, new_end); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - } - } - } - - #[test] - fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { - for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ - ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. - ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. - ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. - ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), - ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. - ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), - ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. - ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. - ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. - ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), - ("abc", (1, 3), "abc", "", "", (0, 0), 1), - ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. - ] { - let (mut s, sid) = scope_session(text); - scope_select(&mut s, sid, a, b); - let mut expected = s.doc().unwrap().selection.clone(); - let t = expected.text.as_mut().unwrap(); - (t.anchor, t.focus) = (after_a, after_b); - assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); - assert_eq!(st.selection, expected, "{text} {a}->{b}"); - assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); - if after_a == after_b { - assert_eq!( - s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), - json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) - ); - } - } - } - - #[test] - fn find_scope_change_other_story_and_no_hits_preserve_selection() { - for explicit in [false, true] { - let (mut s, sid) = scope_session("éX"); - let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); - let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); - let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, sid, 3, 2); - let before = s.doc().unwrap().selection.clone(); - let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); - if explicit { - p["story"] = json!(other_sid.0); - } - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); - assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); - assert_eq!(s.doc().unwrap().selection, before); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); - assert_eq!(s.doc().unwrap().selection, before); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); - assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); - - s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); - let before = s.doc().unwrap().selection.clone(); - assert!(before.text.is_none()); - p["find"] = json!("🙂"); - p["change"] = json!("é"); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - assert_eq!(s.doc().unwrap().selection, before); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); - assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); - } - } - - #[test] - fn find_scope_mixed_grep_matches_count_next_and_change() { - for (text, pattern, expected, changed) in [ - ("a", "a*", vec![(0, 1)], "X"), - ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), - ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), - ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), - ] { - let (mut s, sid) = scope_session(text); - scope_select(&mut s, sid, 0, text.len()); - let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); - let counted = s.execute("find.find", &p).unwrap(); - let ranges: Vec<_> = - counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); - assert_eq!(ranges, expected, "{pattern} on {text}"); - for (a, b) in &expected { - scope_hit(&mut s, &p, sid, *a, *b); - } - for _ in 0..2 { - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); - } - assert_eq!(s.execute("find.find", &p).unwrap(), counted); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); - } - } - - #[test] - fn find_scope_capture_replacement_and_first_keep_original_domain() { - let (mut s, sid) = scope_session("a1 a2 | a3"); - scope_select(&mut s, sid, 0, 5); - let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); - scope_hit(&mut s, &p, sid, 0, 2); - scope_hit(&mut s, &p, sid, 3, 5); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); - } - - #[test] - fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { - for (a, b) in [(0, 7), (7, 0)] { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, a, b); - let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let revision = s.doc().unwrap().revision; - let history = s.doc().unwrap().history.undo.len(); - let strong = std::sync::Arc::strong_count(&before); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - assert_eq!(s.doc().unwrap().selection, selection); - assert_eq!(s.doc().unwrap().revision, revision); - assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); - scope_hit(&mut s, &p, sid, 0, 3); - scope_hit(&mut s, &p, sid, 4, 7); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); - let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); - assert_eq!(changed.text, "blue blue | red"); - assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); - assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); - assert!(s.find_context.is_none()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - assert!(s.find_context.is_none()); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); - let native = s.execute("file.serialize", &json!({})).unwrap(); - s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); - assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); - assert!(s.find_context.is_none()); - } - } - - #[test] - fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { - let (mut s, sid) = scope_session("red blue red | blue red"); - scope_select(&mut s, sid, 0, 12); - let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); - scope_hit(&mut s, &p, sid, 0, 3); - p["change"] = json!("two"); - p["attrs"] = json!({"fontStyle": "Italic"}); - scope_hit(&mut s, &p, sid, 9, 12); - p["find"] = json!("blue"); - scope_hit(&mut s, &p, sid, 4, 8); - p["find"] = json!("red"); - scope_hit(&mut s, &p, sid, 0, 3); - p["caseSensitive"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); - p["grep"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. - p["find"] = json!("r.d"); - scope_hit(&mut s, &p, sid, 0, 3); - p["wholeWord"] = json!(true); - scope_hit(&mut s, &p, sid, 0, 3); - let hit = s.doc().unwrap().selection.text.unwrap(); - scope_select(&mut s, sid, hit.anchor, hit.focus); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); - scope_hit(&mut s, &p, sid, 0, 3); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - scope_select(&mut s, sid, 20, 23); - p["find"] = json!("red"); - scope_hit(&mut s, &p, sid, 20, 23); - } - - #[test] - fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "change": "blue", "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - scope_hit(&mut s, &p, sid, 0, 3); - let selected = s.doc().unwrap().selection.clone(); - let frame = selected.text.unwrap().frame.unwrap(); - let point = { - let st = s.doc().unwrap(); - let cs = s.cache.get(&st.doc, sid, None); - let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); - let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); - assert_eq!(cs.frames[fi].frame, frame); - assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); - let loc = st.doc.find(frame).unwrap(); - st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner - }; - let before = s.doc().unwrap().doc.clone(); - let revision = s.doc().unwrap().revision; - let history = s.doc().unwrap().history.undo.len(); - s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); - assert!(!s.text_drag, "explicit word reselection does not start a text drag"); - assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); - assert!(s.doc().unwrap().revision > revision, "a new revision"); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert!(s.find_context.is_none()); - assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); - assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); - assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); - } - - #[test] - fn find_scope_story_explicit_story_document_order_and_scope_changes() { - let (mut s, a) = scope_session("black red"); - let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); - let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); - scope_select(&mut s, a, 0, 9); - let story = json!({"find": "red", "scope": "story"}); - assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); - scope_select(&mut s, b, 0, 0); - let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); - scope_hit(&mut s, &explicit, a, 0, 5); - let selection = s.doc().unwrap().selection.clone(); - assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); - assert_eq!(s.doc().unwrap().selection, selection); - scope_select(&mut s, a, 0, 9); - scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); - let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); - assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); - assert_eq!(counted[1]["story"], b.0); - scope_select(&mut s, a, 0, 9); - scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); - assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); - s.execute("selection.set", &json!({"ids": []})).unwrap(); - for command in ["find.find", "find.next", "find.change"] { - assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); - } - } - - #[test] - fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { - let (mut s, sid) = scope_session("scat é🙂 red"); - scope_select(&mut s, sid, 1, 4); - let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); - scope_hit(&mut s, &p, sid, 1, 4); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); - scope_select(&mut s, sid, 1, 4); - let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); - scope_hit(&mut s, &p, sid, 1, 2); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); - scope_select(&mut s, sid, 5, 11); - let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); - for pos in [5, 7, 11] { - scope_hit(&mut s, &empty, sid, pos, pos); - } - for _ in 0..2 { - assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); - } - assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); - scope_select(&mut s, sid, 7, 7); - scope_hit(&mut s, &empty, sid, 7, 7); - assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); - scope_select(&mut s, sid, 12, 15); - let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); - scope_hit(&mut s, &nonempty, sid, 12, 15); - assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); - for range in [6..7, 0..usize::MAX] { - let st = s.doc_mut().unwrap(); - st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { - story: sid, - anchor: range.start, - focus: range.end, - frame: None, - cell: None, - }); - st.bump_revision(); - for command in ["find.find", "find.next", "find.change"] { - let before = s.doc().unwrap().doc.clone(); - let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); - assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - } - } - } - - #[test] - fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "scope": "selection"}); - scope_hit(&mut s, &p, sid, 0, 3); - let selected = s.doc().unwrap().selection.clone(); - assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); - assert_eq!(s.doc().unwrap().selection, selected); - scope_hit(&mut s, &p, sid, 4, 7); - assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); - } - - #[test] - fn find_scope_document_switch_edit_and_reset() { - let (mut s, sid) = scope_session("red red | red"); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "scope": "selection"}); - scope_hit(&mut s, &p, sid, 0, 3); - s.execute("file.new", &json!({})).unwrap(); - s.set_active(0); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); - scope_select(&mut s, sid, 0, 7); - scope_hit(&mut s, &p, sid, 0, 3); - s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); - assert!(s.find_context.is_none()); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); - s.reset_find_scope(); - assert!(s.find_context.is_none()); - } - - #[test] - fn find_scope_real_preview_restores_before_resolving_bounds() { - let (mut s, sid) = scope_session(""); - s.execute( - "data.source.select", - &json!({ - "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" - }), - ) - .unwrap(); - s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); - let template = s.doc().unwrap().doc.clone(); - assert_eq!(template.story(sid).unwrap().text, "<>"); - s.execute("data.preview", &json!({"record": 1})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - // Install a viewport selection in the real preview. text.select is journaled - // and would itself restore the template before selecting. - let st = s.doc_mut().unwrap(); - st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); - st.bump_revision(); - let p = json!({"find": "red", "change": "blue", "scope": "selection"}); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); - let history = s.doc().unwrap().history.undo.len(); - let err = s.execute("find.change", &p).unwrap_err(); - assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); - assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert!(s.doc().unwrap().preview_stash.is_none()); - assert!(s.find_context.is_none()); - // Valid fresh bounds are allowed after restoration; no cached preview range - // survives. A document query can see the preview, but Next sees the template. - s.execute("data.preview", &json!({"record": 1})).unwrap(); - let document = json!({"find": "red"}); - assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); - assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); - assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); - assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - } - - #[test] - fn document_scope_searches_and_changes_table_cells() { - let (mut s, sid) = scope_session("Ore 12.15 here"); - scope_select(&mut s, sid, 0, 0); - let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap(); - // The caret sits in the first cell, so Find Next goes to the second cell, then the story. - let p = json!({"find": "Ore 12.15", "scope": "document"}); - let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 }; - assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}})); - assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell)))); - let next = s.execute("find.next", &p).unwrap(); - assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}"); - let hits = s.execute("find.find", &p).unwrap(); - let hits = hits.as_array().unwrap(); - assert_eq!(hits.len(), 2); - let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect(); - assert_eq!(in_cell.len(), 1); - assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1))); - let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap(); - assert_eq!(n["count"], 2); - assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty()); - assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); - } - - fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { - let (mut s, sid) = scope_session("red"); - scope_select(&mut s, sid, 3, 3); - if note { - s.execute("footnote.insert", &json!({"text": child})).unwrap(); - } else { - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); - } - let selected = s.doc().unwrap().selection.text.unwrap(); - (s, sid, selected) - } - - #[test] - fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { - for note in [false, true] { - for child in ["red", "red x", "é red red red red red"] { - let (mut s, sid, mut selected) = child_scope_session(note, child); - selected.anchor = 0; - selected.focus = child.len(); - s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); - let before = s.doc().unwrap().doc.clone(); - let selection = s.doc().unwrap().selection.clone(); - let history = s.doc().unwrap().history.undo.len(); - let revision = s.doc().unwrap().revision; - let journal = s.journal.clone(); - for scope in ["story", "selection"] { - for command in ["find.find", "find.next", "find.change"] { - let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); - assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); - assert!(err.to_string().contains("not supported")); - assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - assert_eq!(s.doc().unwrap().selection, selection); - assert_eq!(s.doc().unwrap().history.undo.len(), history); - assert_eq!(s.doc().unwrap().revision, revision); - assert_eq!(s.journal, journal); - } - } - for explicit in [false, true] { - let mut p = json!({"find": "red", "change": "blue"}); - if explicit { - p["story"] = json!(sid.0); - p["scope"] = json!("selection"); - } - // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level. - let in_cell = if explicit || note { 0 } else { child.matches("red").count() }; - let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() }; - s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell); - scope_hit(&mut s, &p, sid, 0, 3); - s.doc_mut().unwrap().selection = selection.clone(); - assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell); - assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed); - assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); - p["find"] = json!("blue"); - p["change"] = json!("red"); - s.execute("find.change", &p).unwrap(); - } - } - } - let (mut s, _, _) = child_scope_session(false, "red"); - s.execute("table.selectTable", &json!({})).unwrap(); - for scope in ["story", "selection"] { - for command in ["find.find", "find.next", "find.change"] { - assert!( - s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported") - ); - } - } - } - - #[test] - fn find_scope_threaded_overset_story_uses_logical_offsets() { - let text = format!("{}red", "word ".repeat(100)); - let (mut s, sid) = scope_session(&text); - let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; - let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); - let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); - s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); - scope_select(&mut s, sid, 0, 0); - scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); - assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); - } - - #[test] - fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { - for scope in ["story", "explicit", "document"] { - let (mut s, sid) = scope_session("é🙂"); - scope_select(&mut s, sid, 0, 0); - let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); - if scope == "explicit" { - p["story"] = json!(sid.0); - } - for pos in [0, 2, 6] { - scope_hit(&mut s, &p, sid, pos, pos); - } - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); - } - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn find_scope_revert_releases_context_with_same_document_uid() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("scope.designcraft"); - let (mut s, sid) = scope_session("red red | red"); - s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); - scope_select(&mut s, sid, 0, 7); - let p = json!({"find": "red", "scope": "selection"}); - scope_hit(&mut s, &p, sid, 0, 3); - let uid = s.doc().unwrap().uid; - s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); - scope_select(&mut s, sid, 0, 7); - scope_hit(&mut s, &p, sid, 0, 3); - s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().uid, uid); - assert!(s.find_context.is_none()); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); - assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); - } -} +mod tests; /// Objects matching Find/Change › Object criteria, in document order (nested ones included). fn find_objects(d: &designcraft_doc::Document, p: &Value) -> Vec { diff --git a/crates/engine/src/cmd/find/tests.rs b/crates/engine/src/cmd/find/tests.rs new file mode 100644 index 000000000..5914c2e51 --- /dev/null +++ b/crates/engine/src/cmd/find/tests.rs @@ -0,0 +1,749 @@ +use serde_json::json; + +use crate::Session; + +#[test] +fn find_and_change_text_and_grep() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Call 555-1234 or 555-9876.\nColour and colour."})) + .unwrap(); + let m = s.execute("find.find", &json!({"find": "colour"})).unwrap(); + assert_eq!(m.as_array().unwrap().len(), 2); + let m = s.execute("find.find", &json!({"find": "colour", "caseSensitive": true})).unwrap(); + assert_eq!(m.as_array().unwrap().len(), 1); + let r = s.execute("find.change", &json!({"find": r"(\d{3})-(\d{4})", "change": "($1) $2", "grep": true})).unwrap(); + assert_eq!(r["count"], 2); + let r = s.execute("find.change", &json!({"find": "colour", "change": "color", "attrs": {"fontStyle": "Italic"}})).unwrap(); + assert_eq!(r["count"], 2); + let d = s.execute("document.inspect", &json!({})).unwrap(); + let sid = d["stories"][0]["id"].as_u64().unwrap(); + let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "Call (555) 1234 or (555) 9876.\ncolor and color."); + // Paragraph metacharacter in text mode. + assert_eq!(s.execute("find.find", &json!({"find": ".^pc"})).unwrap().as_array().unwrap().len(), 1); + s.execute("find.next", &json!({"find": "or"})).unwrap(); + assert!(s.doc().unwrap().selection.text.is_some()); +} + +#[test] +fn change_first_changes_one_match_in_the_whole_document() { + for grep in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for y in [36, 336] { + s.execute("frame.create", &json!({"rect": [36, y, 300, y + 200], "content": "text", "text": "red and red"})).unwrap(); + } + let r = s.execute("find.change", &json!({"find": "red", "change": "blue", "first": true, "grep": grep})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(s.execute("find.find", &json!({"find": "blue"})).unwrap().as_array().unwrap().len(), 1); + assert_eq!(s.execute("find.find", &json!({"find": "red"})).unwrap().as_array().unwrap().len(), 3); + } +} + +fn scope_session(text: &str) -> (Session, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s.execute("frame.create", &json!({"rect": [36, 36, 100, 100], "content": "text", "text": text})).unwrap(); + (s, designcraft_doc::StoryId(frame["story"].as_u64().unwrap())) +} + +fn scope_select(s: &mut Session, sid: designcraft_doc::StoryId, a: usize, b: usize) { + s.execute("text.select", &json!({"story": sid.0, "anchor": a, "focus": b})).unwrap(); +} + +fn scope_hit(s: &mut Session, p: &serde_json::Value, sid: designcraft_doc::StoryId, a: usize, b: usize) { + assert_eq!(s.execute("find.next", p).unwrap(), json!({"story": sid.0, "start": a, "end": b})); + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.story, selected.range(), selected.cell), (sid, a..b, None)); +} + +fn assert_document_change_scenarios() { + for (texts, first, explicit, grep, expected, count) in [ + (vec!["red red", "red"], Some(true), false, false, vec!["blue red", "red"], 1), + (vec!["keep", "red red", "red"], Some(true), false, false, vec!["keep", "blue red", "red"], 1), + (vec!["keep", "other"], Some(true), false, false, vec!["keep", "other"], 0), + (vec!["red red", "red"], Some(false), false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], None, false, false, vec!["blue blue", "blue"], 3), + (vec!["red red", "red"], Some(true), true, false, vec!["blue red", "red"], 1), + (vec!["red red", "red"], Some(false), true, false, vec!["blue blue", "red"], 2), + (vec!["", "é🙂"], Some(true), false, true, vec!["X", "é🙂"], 1), + (vec!["é", "🙂"], Some(false), false, true, vec!["XéX", "X🙂X"], 4), + ] { + let (mut s, sid) = scope_session(texts[0]); + let mut sids = vec![sid]; + for text in texts.iter().skip(1) { + let frame = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": text})).unwrap(); + sids.push(designcraft_doc::StoryId(frame["story"].as_u64().unwrap())); + } + assert_eq!(s.doc().unwrap().doc.stories.keys().copied().collect::>(), sids); + // Document Change keeps canonical target order even with a later story current. + scope_select(&mut s, *sids.last().unwrap(), 0, 0); + let before = s.doc().unwrap().doc.clone(); + let mut p = json!({ + "find": if grep { "(?:)" } else { "red" }, + "change": if grep { "X" } else { "blue" }, + "scope": "document", "grep": grep + }); + if let Some(first) = first { + p["first"] = json!(first); + } + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + if first == Some(true) && !explicit && !grep && texts.last() == Some(&"red") { + let last = *sids.last().unwrap(); + scope_hit(&mut s, &p, last, 0, 3); + let first_hit = sids[if texts.len() == 2 { 0 } else { 1 }]; + assert_eq!( + s.execute("find.find", &p).unwrap(), + json!([ + {"story": first_hit.0, "start": 0, "end": 3, "text": "red"}, + {"story": first_hit.0, "start": 4, "end": 7, "text": "red"}, + {"story": last.0, "start": 0, "end": 3, "text": "red"} + ]), + "Count remains canonical after Next paints the later story" + ); + } + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.selection, selection); + for (i, sid) in sids.iter().enumerate() { + assert_eq!(st.doc.story(*sid).unwrap().text, expected[i]); + if texts[i] == expected[i] { + assert_eq!(st.doc.story(*sid), before.story(*sid), "untouched story {i} stays entirely unchanged"); + } + } + st.doc.check().unwrap(); + } +} + +#[test] +fn find_scope_first_change_stops_after_first_matching_story_and_repairs_selection() { + assert_document_change_scenarios(); + + // Identical replacement is still one counted match, not a reason to visit another story. + let (mut s, sid) = scope_session("red red"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "red"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, other_sid, 0, 3); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "red", "scope": "document", "first": true})).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red"); + assert_eq!(s.doc().unwrap().doc.story(other_sid), before.story(other_sid)); + assert_eq!(s.doc().unwrap().selection, selection); + s.doc().unwrap().doc.check().unwrap(); + + for reverse in [false, true] { + let (mut s, sid) = scope_session("éX X"); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "X"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, if reverse { 3 } else { 2 }, if reverse { 2 } else { 3 }); + let before = s.doc().unwrap().doc.clone(); + let mut selection = s.doc().unwrap().selection.clone(); + let t = selection.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (6, 2) } else { (2, 6) }; + let p = json!({"find": "X", "change": "🙂", "scope": "document", "first": true}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, "é🙂 X"); + assert_eq!(st.doc.story(other_sid), before.story(other_sid)); + assert_eq!(st.selection, selection, "first-match exit must follow final UTF-8 repair"); + st.doc.check().unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "🙂", "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": 2, "end": 6, "text": "🙂"}]) + ); + } +} + +#[test] +fn grep_in_the_documents_dialect() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Москва and Paris, 1\u{2013}2 days.\nok"})).unwrap(); + // `\u` uppercase, `\l` lowercase letter. + let m = s.execute("find.find", &json!({"find": r"\u\l+", "grep": true, "caseSensitive": true})).unwrap(); + let words: Vec<&str> = m.as_array().unwrap().iter().map(|x| x["text"].as_str().unwrap()).collect(); + assert_eq!(words, ["Москва", "Paris"]); + // `~=` en dash, `\h` space, `\r` paragraph end; the change uses groups, `~S` and `\r`. + let r = s.execute("find.change", &json!({"find": r"(\d)~=(\d)\h(\l+)\.\r", "change": r"$1~=$2~S$3!\r", "grep": true})).unwrap(); + assert_eq!(r["count"], 1); + let sid = s.execute("document.inspect", &json!({})).unwrap()["stories"][0]["id"].as_u64().unwrap(); + let text = s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.starts_with("Москва and Paris, 1\u{2013}2\u{A0}days!\nok"), "{text:?}"); + // A pattern that doesn't compile is an error, not a silent no-op. + assert!(s.execute("find.find", &json!({"find": "[[:nope:]]", "grep": true})).is_err()); + assert!(s.execute("find.change", &json!({"find": "(", "change": "", "grep": true})).is_err()); +} + +#[test] +fn find_scope_exact_baseline_count_next_change() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let frame = s + .execute( + "frame.create", + &json!({ + "rect": [36, 36, 300, 200], "content": "text", "text": "red red" + }), + ) + .unwrap(); + let sid = frame["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + assert_eq!( + s.execute("find.find", &json!({"find": "red", "scope": "selection"})).unwrap(), + json!([{"story": sid, "start": 0, "end": 3, "text": "red"}]) + ); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "selection"})).unwrap(), json!({"story": sid, "start": 0, "end": 3})); + assert_eq!(s.execute("find.change", &json!({"find": "red", "change": "blue", "scope": "selection"})).unwrap(), json!({"count": 1})); + assert_eq!(s.execute("story.get", &json!({"story": sid})).unwrap()["text"], "blue red"); +} + +#[test] +fn find_scope_change_growth_preserves_direction_and_full_scalar_hits() { + for (text, find, change, changed, start, old_end, new_end) in [("a", "a", "é", "é", 0, 1, 2), ("éX", "X", "🙂", "é🙂", 2, 3, 6)] { + for reverse in [false, true] { + let (mut s, sid) = scope_session(text); + // Real caret movement keeps the frame identity, unlike text.select. + s.execute("text.move", &json!({"dir": "left", "extend": reverse})).unwrap(); + if !reverse { + s.execute("text.move", &json!({"dir": "right", "extend": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((selected.anchor, selected.focus), if reverse { (old_end, start) } else { (start, old_end) }); + assert!(selected.frame.is_some()); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = if reverse { (new_end, start) } else { (start, new_end) }; + let p = json!({"find": find, "change": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": old_end, "text": find}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed); + assert_eq!(st.selection, expected); + let t = st.selection.text.unwrap(); + assert!(changed.is_char_boundary(t.anchor) && changed.is_char_boundary(t.focus)); + assert!(s.find_context.is_none()); + let p = json!({"find": change, "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": start, "end": new_end, "text": change}])); + scope_hit(&mut s, &p, sid, start, new_end); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } + } +} + +#[test] +fn find_scope_change_repairs_final_boundaries_without_remapping_valid_offsets() { + for (text, (a, b), find, change, changed, (after_a, after_b), count) in [ + ("aX", (1, 1), "a", "é", "éX", (0, 0), 1), // A caret stays collapsed and floors. + ("aX", (2, 2), "a", "é", "éX", (2, 2), 1), // A valid caret stays at its byte offset. + ("abc", (1, 2), "abc", "🙂", "🙂", (0, 4), 1), // Both endpoints need repair. + ("abc", (2, 1), "abc", "🙂", "🙂", (4, 0), 1), + ("é🙂", (2, 6), "🙂", "X", "éX", (2, 3), 1), // Existing shortening behavior. + ("é🙂", (6, 2), "🙂", "X", "éX", (3, 2), 1), + ("red", (0, 3), "red", "blue", "blue", (0, 3), 1), // Do not extend a valid upper bound. + ("aX", (1, 2), "a", "zz", "zzX", (1, 2), 1), // Do not track the original X. + ("aaX", (1, 2), "a", "é", "ééX", (0, 2), 2), // Per-edit repair would wrongly yield 0..4. + ("aaX", (2, 3), "a", "é", "ééX", (2, 4), 2), + ("abc", (1, 3), "abc", "", "", (0, 0), 1), + ("abc", (2, 3), "abc", "é", "é", (2, 2), 1), // EOF clamp may collapse a nonempty range. + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, a, b); + let mut expected = s.doc().unwrap().selection.clone(); + let t = expected.text.as_mut().unwrap(); + (t.anchor, t.focus) = (after_a, after_b); + assert_eq!(s.execute("find.change", &json!({"find": find, "change": change, "scope": "document"})).unwrap(), json!({"count": count})); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().text, changed, "{text} {a}->{b}"); + assert_eq!(st.selection, expected, "{text} {a}->{b}"); + assert!(changed.is_char_boundary(after_a) && changed.is_char_boundary(after_b)); + if after_a == after_b { + assert_eq!( + s.execute("find.find", &json!({"find": "(?:)", "grep": true, "scope": "selection"})).unwrap(), + json!([{"story": sid.0, "start": after_a, "end": after_a, "text": ""}]) + ); + } + } +} + +#[test] +fn find_scope_change_other_story_and_no_hits_preserve_selection() { + for explicit in [false, true] { + let (mut s, sid) = scope_session("éX"); + let frame = s.doc().unwrap().selection.text.unwrap().frame.unwrap(); + let other = s.execute("frame.create", &json!({"rect": [120, 36, 200, 100], "content": "text", "text": "a"})).unwrap(); + let other_sid = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, sid, 3, 2); + let before = s.doc().unwrap().selection.clone(); + let mut p = json!({"find": "a", "change": "🙂", "scope": "document"}); + if explicit { + p["story"] = json!(other_sid.0); + } + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 0})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "🙂"); + + s.execute("selection.set", &json!({"ids": [frame.0]})).unwrap(); + let before = s.doc().unwrap().selection.clone(); + assert!(before.text.is_none()); + p["find"] = json!("🙂"); + p["change"] = json!("é"); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().selection, before); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "éX"); + assert_eq!(s.doc().unwrap().doc.story(other_sid).unwrap().text, "é"); + } +} + +#[test] +fn find_scope_mixed_grep_matches_count_next_and_change() { + for (text, pattern, expected, changed) in [ + ("a", "a*", vec![(0, 1)], "X"), + ("aa", "a|(?:)", vec![(0, 1), (1, 2)], "XX"), + ("ab", "a*", vec![(0, 1), (2, 2)], "XbX"), + ("éa", "é|(?:)", vec![(0, 2), (3, 3)], "XaX"), + ] { + let (mut s, sid) = scope_session(text); + scope_select(&mut s, sid, 0, text.len()); + let p = json!({"find": pattern, "grep": true, "scope": "selection", "change": "X"}); + let counted = s.execute("find.find", &p).unwrap(); + let ranges: Vec<_> = + counted.as_array().unwrap().iter().map(|m| (m["start"].as_u64().unwrap() as usize, m["end"].as_u64().unwrap() as usize)).collect(); + assert_eq!(ranges, expected, "{pattern} on {text}"); + for (a, b) in &expected { + scope_hit(&mut s, &p, sid, *a, *b); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "{pattern} on {text}"); + } + assert_eq!(s.execute("find.find", &p).unwrap(), counted); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], expected.len()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, changed); + } +} + +#[test] +fn find_scope_capture_replacement_and_first_keep_original_domain() { + let (mut s, sid) = scope_session("a1 a2 | a3"); + scope_select(&mut s, sid, 0, 5); + let p = json!({"find": r"a(\d)", "grep": true, "scope": "selection", "change": "b$1", "first": true}); + scope_hit(&mut s, &p, sid, 0, 2); + scope_hit(&mut s, &p, sid, 3, 5); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "b1 a2 | a3"); +} + +#[test] +fn find_scope_selection_retains_nonempty_hits_count_change_and_native_text() { + for (a, b) in [(0, 7), (7, 0)] { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, a, b); + let p = json!({"find": "red", "change": "blue", "scope": "selection", "attrs": {"fontStyle": "Italic"}}); + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + let strong = std::sync::Arc::strong_count(&before); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(std::sync::Arc::strong_count(&before), strong, "search stores weak document identity"); + scope_hit(&mut s, &p, sid, 0, 3); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 2); + let changed = s.doc().unwrap().doc.story(sid).unwrap().clone(); + assert_eq!(changed.text, "blue blue | red"); + assert_eq!(changed.char_format_at(0).over.font_style.as_deref(), Some("Italic")); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + assert!(s.find_context.is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.find_context.is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap(), &changed); + let native = s.execute("file.serialize", &json!({})).unwrap(); + s.execute("file.openBytes", &json!({"name": "scope.designcraft", "base64": native["base64"]})).unwrap(); + assert_eq!(serde_json::to_value(s.doc().unwrap().doc.story(sid).unwrap()).unwrap(), serde_json::to_value(&changed).unwrap()); + assert!(s.find_context.is_none()); + } +} + +#[test] +fn find_scope_matcher_restart_replacement_continuation_and_external_reselection() { + let (mut s, sid) = scope_session("red blue red | blue red"); + scope_select(&mut s, sid, 0, 12); + let mut p = json!({"find": "red", "scope": "selection", "change": "one"}); + scope_hit(&mut s, &p, sid, 0, 3); + p["change"] = json!("two"); + p["attrs"] = json!({"fontStyle": "Italic"}); + scope_hit(&mut s, &p, sid, 9, 12); + p["find"] = json!("blue"); + scope_hit(&mut s, &p, sid, 4, 8); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 0, 3); + p["caseSensitive"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + p["grep"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); // GREP alone changes matcher identity. + p["find"] = json!("r.d"); + scope_hit(&mut s, &p, sid, 0, 3); + p["wholeWord"] = json!(true); + scope_hit(&mut s, &p, sid, 0, 3); + let hit = s.doc().unwrap().selection.text.unwrap(); + scope_select(&mut s, sid, hit.anchor, hit.focus); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1, "same-range external reselection starts a new domain"); + scope_hit(&mut s, &p, sid, 0, 3); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 20, 23); + p["find"] = json!("red"); + scope_hit(&mut s, &p, sid, 20, 23); +} + +#[test] +fn find_scope_word_reselection_at_same_hit_recaptures_bounds() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + let frame = selected.text.unwrap().frame.unwrap(); + let point = { + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, sid, None); + let (fi, x, baseline, ascent, _) = designcraft_compose::caret(&cs, 1).unwrap(); + let inner = designcraft_geom::Point::new(x, baseline - ascent * 0.5); + assert_eq!(cs.frames[fi].frame, frame); + assert_eq!(designcraft_compose::hit(&cs, fi, inner), Some(1), "real point lies inside the current hit"); + let loc = st.doc.find(frame).unwrap(); + st.doc.parent_xf(&loc) * st.doc.text_xf(st.doc.item(frame).unwrap()) * inner + }; + let before = s.doc().unwrap().doc.clone(); + let revision = s.doc().unwrap().revision; + let history = s.doc().unwrap().history.undo.len(); + s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); + assert!(!s.text_drag, "explicit word reselection does not start a text drag"); + assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); + assert!(s.doc().unwrap().revision > revision, "a new revision"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap(), json!([{"story": sid.0, "start": 0, "end": 3, "text": "red"}])); + assert_eq!(s.execute("find.change", &p).unwrap(), json!({"count": 1})); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "blue red | red"); +} + +#[test] +fn find_scope_story_explicit_story_document_order_and_scope_changes() { + let (mut s, a) = scope_session("black red"); + let other = s.execute("frame.create", &json!({"rect": [150, 36, 100, 100], "content": "text", "text": "red"})).unwrap(); + let b = designcraft_doc::StoryId(other["story"].as_u64().unwrap()); + scope_select(&mut s, a, 0, 9); + let story = json!({"find": "red", "scope": "story"}); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "Story does not enter B or wrap to its prefix"); + scope_select(&mut s, b, 0, 0); + let explicit = json!({"find": "black", "story": a.0, "scope": "selection"}); + scope_hit(&mut s, &explicit, a, 0, 5); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "red", "story": u64::MAX})).is_err()); + assert_eq!(s.doc().unwrap().selection, selection); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red"}), b, 0, 3); + let counted = s.execute("find.find", &json!({"find": "red"})).unwrap(); + assert_eq!(counted[0]["story"], a.0, "Count retains canonical story order"); + assert_eq!(counted[1]["story"], b.0); + scope_select(&mut s, a, 0, 9); + scope_hit(&mut s, &json!({"find": "red", "scope": "selection"}), a, 6, 9); + assert_eq!(s.execute("find.next", &story).unwrap(), serde_json::Value::Null, "scope change uses the current caret"); + s.execute("selection.set", &json!({"ids": []})).unwrap(); + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": "selection"})).is_err()); + } +} + +#[test] +fn find_scope_substring_context_utf8_zero_width_and_invalid_ranges() { + let (mut s, sid) = scope_session("scat é🙂 red"); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "cat", "wholeWord": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 4); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 1, 4); + let p = json!({"find": "^c|^a", "grep": true, "scope": "selection"}); + scope_hit(&mut s, &p, sid, 1, 2); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null, "cursor does not redefine ^"); + scope_select(&mut s, sid, 5, 11); + let empty = json!({"find": "(?:)", "grep": true, "scope": "selection"}); + for pos in [5, 7, 11] { + scope_hit(&mut s, &empty, sid, pos, pos); + } + for _ in 0..2 { + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + } + assert_eq!(s.execute("find.find", &empty).unwrap().as_array().unwrap().len(), 3); + scope_select(&mut s, sid, 7, 7); + scope_hit(&mut s, &empty, sid, 7, 7); + assert_eq!(s.execute("find.next", &empty).unwrap(), serde_json::Value::Null); + scope_select(&mut s, sid, 12, 15); + let nonempty = json!({"find": "red", "change": "blue", "scope": "selection"}); + scope_hit(&mut s, &nonempty, sid, 12, 15); + assert_eq!(s.execute("find.change", &nonempty).unwrap()["count"], 1); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "scat é🙂 blue"); + for range in [6..7, 0..usize::MAX] { + let st = s.doc_mut().unwrap(); + st.selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: range.start, focus: range.end, frame: None, cell: None }); + st.bump_revision(); + for command in ["find.find", "find.next", "find.change"] { + let before = s.doc().unwrap().doc.clone(); + let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + } +} + +#[test] +fn find_scope_invalid_query_keeps_continuation_and_cross_boundary_nonmatches() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let selected = s.doc().unwrap().selection.clone(); + assert!(s.execute("find.next", &json!({"find": "[", "grep": true, "scope": "selection"})).is_err()); + assert_eq!(s.doc().unwrap().selection, selected); + scope_hit(&mut s, &p, sid, 4, 7); + assert_eq!(s.execute("find.find", &json!({"find": "red |", "scope": "selection"})).unwrap(), json!([])); +} + +#[test] +fn find_scope_document_switch_edit_and_reset() { + let (mut s, sid) = scope_session("red red | red"); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.new", &json!({})).unwrap(); + s.set_active(0); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("story.setText", &json!({"story": sid.0, "text": "red"})).unwrap(); + assert!(s.find_context.is_none()); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1); + s.reset_find_scope(); + assert!(s.find_context.is_none()); +} + +#[test] +fn find_scope_real_preview_restores_before_resolving_bounds() { + let (mut s, sid) = scope_session(""); + s.execute( + "data.source.select", + &json!({ + "bytes": crate::cmd::base64_encode(b"x\nred red | red\n"), "name": "scope.csv" + }), + ) + .unwrap(); + s.execute("data.placeholder.add", &json!({"field": "x", "story": sid.0, "at": 0})).unwrap(); + let template = s.doc().unwrap().doc.clone(); + assert_eq!(template.story(sid).unwrap().text, "<>"); + s.execute("data.preview", &json!({"record": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + // Install a viewport selection in the real preview. text.select is journaled + // and would itself restore the template before selecting. + let st = s.doc_mut().unwrap(); + st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); + st.bump_revision(); + let p = json!({"find": "red", "change": "blue", "scope": "selection"}); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); + let history = s.doc().unwrap().history.undo.len(); + let err = s.execute("find.change", &p).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "<>"); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(s.doc().unwrap().preview_stash.is_none()); + assert!(s.find_context.is_none()); + // Valid fresh bounds are allowed after restoration; no cached preview range + // survives. A document query can see the preview, but Next sees the template. + s.execute("data.preview", &json!({"record": 1})).unwrap(); + let document = json!({"find": "red"}); + assert_eq!(s.execute("find.find", &document).unwrap().as_array().unwrap().len(), 3); + assert_eq!(s.execute("find.next", &document).unwrap(), serde_json::Value::Null); + assert!(std::sync::Arc::ptr_eq(&template, &s.doc().unwrap().doc)); + assert_eq!(s.execute("find.find", &document).unwrap(), json!([])); + assert_eq!(s.doc().unwrap().history.undo.len(), history); +} + +#[test] +fn document_scope_searches_and_changes_table_cells() { + let (mut s, sid) = scope_session("Ore 12.15 here"); + scope_select(&mut s, sid, 0, 0); + let table = s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 1, "text": "Ore 12.15"})).unwrap(); + // The caret sits in the first cell, so Find Next goes to the second cell, then the story. + let p = json!({"find": "Ore 12.15", "scope": "document"}); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 1 }; + assert_eq!(s.execute("find.next", &p).unwrap(), json!({"story": sid.0, "start": 0, "end": 9, "cell": {"table": table, "row": 0, "col": 1}})); + assert_eq!(s.doc().unwrap().selection.text.map(|t| (t.story, t.range(), t.cell)), Some((sid, 0..9, Some(cell)))); + let next = s.execute("find.next", &p).unwrap(); + assert!(next.get("start").is_some() && next.get("cell").is_none(), "{next}"); + let hits = s.execute("find.find", &p).unwrap(); + let hits = hits.as_array().unwrap(); + assert_eq!(hits.len(), 2); + let in_cell: Vec<_> = hits.iter().filter(|h| h.get("cell").is_some()).collect(); + assert_eq!(in_cell.len(), 1); + assert_eq!((in_cell[0]["cell"]["row"].as_u64(), in_cell[0]["cell"]["col"].as_u64()), (Some(0), Some(1))); + let n = s.execute("find.change", &json!({"find": "Ore 12.15", "change": "Ore 13.00", "scope": "document"})).unwrap(); + assert_eq!(n["count"], 2); + assert!(s.execute("find.find", &json!({"find": "12.15", "scope": "document"})).unwrap().as_array().unwrap().is_empty()); + assert_eq!(s.execute("find.find", &json!({"find": "13.00", "scope": "document"})).unwrap().as_array().unwrap().len(), 2); +} + +fn child_scope_session(note: bool, child: &str) -> (Session, designcraft_doc::StoryId, designcraft_doc::TextSel) { + let (mut s, sid) = scope_session("red"); + scope_select(&mut s, sid, 3, 3); + if note { + s.execute("footnote.insert", &json!({"text": child})).unwrap(); + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.insert", &json!({"text": child, "raw": true})).unwrap(); + } + let selected = s.doc().unwrap().selection.text.unwrap(); + (s, sid, selected) +} + +/// Child-story scopes are rejected by every find command, leaving the document untouched. +fn assert_child_scope_requests_rejected(s: &mut Session) { + let before = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let revision = s.doc().unwrap().revision; + let journal = s.journal.clone(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + let err = s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err(); + assert!(matches!(err, crate::EngineError::BadParams { .. }), "{err}"); + assert!(err.to_string().contains("not supported")); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + assert_eq!(s.doc().unwrap().selection, selection); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert_eq!(s.doc().unwrap().revision, revision); + assert_eq!(s.journal, journal); + } + } +} + +#[test] +fn find_scope_children_error_atomically_and_parent_requests_stay_top_level() { + for note in [false, true] { + for child in ["red", "red x", "é red red red red red"] { + let (mut s, sid, mut selected) = child_scope_session(note, child); + selected.anchor = 0; + selected.focus = child.len(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(selected); + assert_child_scope_requests_rejected(&mut s); + let selection = s.doc().unwrap().selection.clone(); + for explicit in [false, true] { + let mut p = json!({"find": "red", "change": "blue"}); + if explicit { + p["story"] = json!(sid.0); + p["scope"] = json!("selection"); + } + // Document scope also covers table cells (#1624); an explicit story and footnotes stay top-level. + let in_cell = if explicit || note { 0 } else { child.matches("red").count() }; + let changed = if in_cell > 0 { child.replace("red", "blue") } else { child.to_string() }; + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 1 + in_cell); + scope_hit(&mut s, &p, sid, 0, 3); + s.doc_mut().unwrap().selection = selection.clone(); + assert_eq!(s.execute("find.change", &p).unwrap()["count"], 1 + in_cell); + assert_eq!(s.doc().unwrap().doc.text_story(sid, selected.cell).unwrap().text, changed); + assert_eq!(s.doc().unwrap().selection, selection, "parent replacement does not clamp child offsets"); + p["find"] = json!("blue"); + p["change"] = json!("red"); + s.execute("find.change", &p).unwrap(); + } + } + } + let (mut s, _, _) = child_scope_session(false, "red"); + s.execute("table.selectTable", &json!({})).unwrap(); + for scope in ["story", "selection"] { + for command in ["find.find", "find.next", "find.change"] { + assert!(s.execute(command, &json!({"find": "red", "change": "blue", "scope": scope})).unwrap_err().to_string().contains("not supported")); + } + } +} + +#[test] +fn find_scope_threaded_overset_story_uses_logical_offsets() { + let text = format!("{}red", "word ".repeat(100)); + let (mut s, sid) = scope_session(&text); + let a = s.doc().unwrap().doc.story(sid).unwrap().frames[0]; + let next = s.execute("frame.create", &json!({"rect": [150, 36, 20, 1], "content": "text"})).unwrap(); + let b = designcraft_doc::ItemId(next["id"].as_u64().unwrap()); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().frames.len(), 2); + scope_select(&mut s, sid, 0, 0); + scope_hit(&mut s, &json!({"find": "red", "scope": "story"}), sid, 500, 503); + assert_eq!(s.execute("find.next", &json!({"find": "red", "scope": "story"})).unwrap(), serde_json::Value::Null); +} + +#[test] +fn find_scope_zero_width_story_explicit_and_document_endpoints_terminate() { + for scope in ["story", "explicit", "document"] { + let (mut s, sid) = scope_session("é🙂"); + scope_select(&mut s, sid, 0, 0); + let mut p = json!({"find": "(?:)", "grep": true, "scope": scope}); + if scope == "explicit" { + p["story"] = json!(sid.0); + } + for pos in [0, 2, 6] { + scope_hit(&mut s, &p, sid, pos, pos); + } + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + assert_eq!(s.execute("find.next", &p).unwrap(), serde_json::Value::Null); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn find_scope_revert_releases_context_with_same_document_uid() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("scope.designcraft"); + let (mut s, sid) = scope_session("red red | red"); + s.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + scope_select(&mut s, sid, 0, 7); + let p = json!({"find": "red", "scope": "selection"}); + scope_hit(&mut s, &p, sid, 0, 3); + let uid = s.doc().unwrap().uid; + s.execute("story.setText", &json!({"story": sid.0, "text": "red red"})).unwrap(); + scope_select(&mut s, sid, 0, 7); + scope_hit(&mut s, &p, sid, 0, 3); + s.execute("file.revert", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().uid, uid); + assert!(s.find_context.is_none()); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, "red red | red"); + assert!(s.execute("find.find", &p).is_err(), "revert's empty selection cannot inherit a previous range"); +} diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index b1577400c..ee80eb914 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -81,36 +81,41 @@ fn list(d: &Document) -> Vec { used_fonts(d).into_iter().map(|font| json!(font)).collect() } -pub(super) fn used_fonts(d: &Document) -> Vec { - let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); - let mut used: BTreeMap<(String, String), (usize, std::collections::BTreeSet)> = BTreeMap::new(); - for_each_story(d, &mut |st| { - let ranges = st.para_ranges(); - for (pi, r) in ranges.iter().enumerate() { - let (_, base) = d.styles.resolve_para(&st.paras[pi]); - for (rr, fmt) in st.runs() { - let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count(); - if n == 0 && !(r.is_empty() && rr.contains(&r.start)) { - continue; - } - let p = d.styles.resolve_char(&base, fmt); - if let Some(f) = d.styles.composite_font(&p.font_family) { - let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); - for c in text.chars() { - if let Some(e) = f.entry(c) { - let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); - entry.0 += 1; - entry.1.insert(c); - } +type FontTally = BTreeMap<(String, String), (usize, std::collections::BTreeSet)>; + +/// Count the characters of every run of one story against the font each resolves to. +fn tally_story(d: &Document, st: &Story, used: &mut FontTally) { + let ranges = st.para_ranges(); + for (pi, r) in ranges.iter().enumerate() { + let (_, base) = d.styles.resolve_para(&st.paras[pi]); + for (rr, fmt) in st.runs() { + let n = st.text[rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))].chars().count(); + if n == 0 && !(r.is_empty() && rr.contains(&r.start)) { + continue; + } + let p = d.styles.resolve_char(&base, fmt); + if let Some(f) = d.styles.composite_font(&p.font_family) { + let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or(""); + for c in text.chars() { + if let Some(e) = f.entry(c) { + let entry = used.entry((e.family.clone(), e.style.clone())).or_default(); + entry.0 += 1; + entry.1.insert(c); } - } else { - let entry = used.entry((p.font_family, p.font_style)).or_default(); - entry.0 += n; - entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars()); } + } else { + let entry = used.entry((p.font_family, p.font_style)).or_default(); + entry.0 += n; + entry.1.extend(st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("").chars()); } } - }); + } +} + +pub(super) fn used_fonts(d: &Document) -> Vec { + let db = designcraft_fonts::FontDb::global().scoped(d.font_scope); + let mut used = FontTally::new(); + for_each_story(d, &mut |st| tally_story(d, st, &mut used)); let mut out: Vec = used .into_iter() .map(|((family, style), (n, characters))| { diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs index bf0e011a0..0ddb6a1aa 100644 --- a/crates/engine/src/cmd/interchange.rs +++ b/crates/engine/src/cmd/interchange.rs @@ -28,7 +28,8 @@ pub fn specs() -> Vec { fn export_idml(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) }; - let bytes = designcraft_idml::export_idml_with(&st.doc, &opts); + let layer_doc = with_pdf_layer_variants(&st.doc, opts.embed_images)?; + let bytes = designcraft_idml::export_idml_with(layer_doc.as_ref().unwrap_or(&st.doc), &opts); match str_param(p, "path") { Some(path) => { #[cfg(not(target_arch = "wasm32"))] @@ -39,6 +40,99 @@ fn export_idml(s: &mut Session, p: &Value) -> Result { } } +/// Whether a placement holds an Object Layer Options choice an export can still apply: a graphic +/// whose asset is a PDF. A choice left on an item that is no longer a placed PDF (its link was +/// replaced with another format) is not one, so the package report does not name it. +pub(crate) fn has_applicable_pdf_layer_overrides(doc: &Document) -> bool { + doc.placed_items().into_iter().any(|it| { + it.has_pdf_layer_override() && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data)) + }) +} + +/// Each placement gets its own embedded variant so two frames sharing a PDF can +/// choose different layers. The live document and original linked bytes stay untouched. +/// +/// Parent-page and anchored placements count too, so a page that inherits the placement does not +/// quietly fall back to the file's defaults. Read and write walks also reach table-cell and +/// note text. Endnote variants are prepared too, but IDML endnote structures/artwork are not +/// serialized yet (see `docs/pdf-layer-visibility.md`). +fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result> { + let choices: Vec<_> = doc + .placed_items() + .into_iter() + .filter(|it| it.has_pdf_layer_override()) + .filter_map(|it| { + let asset = it.graphic().and_then(|g| doc.assets.get(&g.asset)).filter(|a| designcraft_render::is_pdf(&a.data))?; + Some((it.id, asset, it.pdf_hidden_layers.clone(), it.pdf_layer_override)) + }) + .collect(); + if choices.is_empty() { + return Ok(None); + } + if !embed { + return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into())); + } + let mut out = doc.clone(); + let mut variants = std::collections::HashMap::new(); + // Duplicated anchors can share an id. Reuse a variant only when source and choice agree. + let mut expected = std::collections::HashMap::new(); + for (id, asset, hidden, overridden) in choices { + let signature = (asset.id, hidden.clone(), overridden); + if let Some(previous) = expected.get(&id) { + if previous != &signature { + return Err(EngineError::Other(format!("PDF layer choices: placements sharing id {id} have different sources or layer choices"))); + } + continue; + } + expected.insert(id, signature); + let bytes = designcraft_render::pdf_hide_layers(&asset.data, &hidden) + .ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?; + let mut variant = (**asset).clone(); + variant.id = designcraft_doc::AssetId(out.alloc()); + variant.data = std::sync::Arc::new(bytes); + variant.link = None; + let aid = variant.id; + out.assets.insert(aid, std::sync::Arc::new(variant)); + variants.insert(id, aid); + } + // Anchored objects live in stories, and parent pages outside the document-spread walk, so + // the choice is cleared where the placement actually is. + // Count distinct ids, allowing identical duplicated anchors, but check every hit against + // the original source and choice before rewriting (including placements using defaults). + let mut reached = std::collections::HashSet::new(); + let mut conflicting_id = None; + out.update_placed_items( + |it| variants.contains_key(&it.id), + |it| { + reached.insert(it.id); + if let Some((asset, hidden, overridden)) = expected.get(&it.id) + && (it.graphic().map(|g| g.asset) != Some(*asset) || &it.pdf_hidden_layers != hidden || it.pdf_layer_override != *overridden) + { + conflicting_id = Some(it.id); + return; + } + if let designcraft_doc::Content::Graphic(g) = &mut it.content + && let Some(aid) = variants.get(&it.id) + { + g.asset = *aid; + } + it.pdf_layer_override = false; + it.pdf_hidden_layers.clear(); + }, + ); + if let Some(id) = conflicting_id { + return Err(EngineError::Other(format!("PDF layer choices: placements sharing id {id} have different sources or layer choices"))); + } + if reached.len() != variants.len() { + return Err(EngineError::Other(format!( + "PDF layer choices could not be applied to every placement (reached {} of {})", + reached.len(), + variants.len() + ))); + } + Ok(Some(out)) +} + /// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from /// beside `package` (the package's file, if it has one), so the automation read root applies to /// the paths the package names, and its PDF crops resolved. @@ -511,8 +605,10 @@ mod tests { for link in links { let (idml, _) = linked_idml(link, false).unwrap(); let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap()); - // Beside the package, and the link itself where it is on this computer. - assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}"); + // Beside the package, and the link itself where it is on this computer (on Windows, + // `/dev/zero` is `\dev\zero` on the package's drive). + let itself = crate::io::document_path(link, Some(&package)).ok(); + assert!(seen.iter().all(|p| p.starts_with(&dir) || itself.as_ref() == Some(p)), "{link}: {seen:?}"); let asset = doc.assets.values().next().unwrap(); assert!(asset.data.is_empty(), "{link}"); assert_eq!(super::super::links::status(asset, None), "missing", "{link}"); @@ -558,3 +654,290 @@ mod tests { assert_eq!(without_document_extension("notes.txt"), "notes.txt"); } } + +#[cfg(test)] +mod nested_layer_tests { + use std::{io::Read, sync::Arc}; + + use designcraft_doc::{AnchoredObject, ItemId, SpreadRef, Story, StoryId, Table}; + use designcraft_geom::Rect; + + use super::*; + + // One default placement and two nested placements share bytes. The latter use a legacy + // nonempty hidden list and explicit all-on, so each needs a different embedded variant. + fn fixture(location: &str) -> (Session, ItemId, [ItemId; 2]) { + let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let placed = session.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap(); + let control = ItemId(placed["id"].as_u64().unwrap()); + let ids = { + let doc = Arc::make_mut(&mut session.doc_mut().unwrap().doc); + let original = doc.item(control).unwrap().clone(); + let aid = original.graphic().unwrap().asset; + Arc::make_mut(doc.assets.get_mut(&aid).unwrap()).link = Some("/synthetic/layers.pdf".into()); + let mut hidden = original.clone(); + hidden.id = ItemId(doc.alloc()); + hidden.pdf_hidden_layers = vec!["Blue".into()]; + let mut all_on = original; + all_on.id = ItemId(doc.alloc()); + all_on.pdf_layer_override = true; + let ids = [hidden.id, all_on.id]; + let mut nested = Story::new(StoryId(0)); + for item in [hidden, all_on] { + nested.insert_object(nested.len(), AnchoredObject { item, position: Default::default() }); + } + let layer = doc.default_layer(); + let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap(); + let story = doc.story_mut(sid).unwrap(); + match location { + "cell" => { + let mut table = Table::new(1, 1, 1, 0, 0, 100.0); + table.cells[0].text = nested; + story.insert_table(0, table); + } + "footnote" => { + story.insert_note(0, "Note", Default::default()).unwrap(); + Arc::make_mut(&mut story.notes[0]).text = nested; + } + _ => { + assert_eq!(location, "endnote"); + story.insert_endnote(0, "Endnote", Default::default()).unwrap(); + Arc::make_mut(&mut story.endnotes[0]).text = nested; + } + } + ids + }; + (session, control, ids) + } + + fn states(blue: bool, red: bool) -> Vec<(String, bool)> { + vec![("Blue".into(), blue), ("Red".into(), red)] + } + + fn prepared(location: &str) { + let (session, control, ids) = fixture(location); + let source = &session.doc().unwrap().doc; + let original_asset = source.item(control).unwrap().graphic().unwrap().asset; + let copy = with_pdf_layer_variants(source, true).unwrap().unwrap(); + for (id, expected) in ids.into_iter().zip([states(false, true), states(true, true)]) { + let item = copy.placed_items().into_iter().find(|i| i.id == id).unwrap(); + let asset = ©.assets[&item.graphic().unwrap().asset]; + assert_eq!(designcraft_render::pdf_layers(&asset.data), expected, "{location}: placement {id}"); + assert_ne!(asset.id, original_asset); + assert!(asset.link.is_none()); + assert!(!item.pdf_layer_override && item.pdf_hidden_layers.is_empty()); + } + assert_eq!(copy.item(control).unwrap().graphic().unwrap().asset, original_asset); + assert!(Arc::ptr_eq(©.assets[&original_asset], &source.assets[&original_asset])); + assert_eq!(designcraft_render::pdf_layers(&source.assets[&original_asset].data), states(true, false)); + assert!(source.placed_items().into_iter().filter(|i| ids.contains(&i.id)).all(|i| i.has_pdf_layer_override())); + } + + fn embedded(location: &str) { + let (mut session, _, _) = fixture(location); + let original = session.doc().unwrap().doc.clone(); + let result = session.execute("file.exportIdml", &json!({})).unwrap(); + let bytes = base64_decode(result["base64"].as_str().unwrap()); + let imported = designcraft_idml::import_idml(&bytes).unwrap(); + let mut roundtrip: Vec<_> = + imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).map(|a| designcraft_render::pdf_layers(&a.data)).collect(); + let mut expected = vec![states(true, false), states(false, true), states(true, true)]; + roundtrip.sort(); + expected.sort(); + assert_eq!(roundtrip, expected, "{location}: reopened IDML keeps each choice"); + let mut package = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut actual = Vec::new(); + for index in 0..package.len() { + let mut entry = package.by_index(index).unwrap(); + if !entry.name().ends_with(".xml") { + continue; + } + let mut xml = String::new(); + entry.read_to_string(&mut xml).unwrap(); + for cdata in xml.split("").next().unwrap()); + if designcraft_render::is_pdf(&data) { + actual.push(designcraft_render::pdf_layers(&data)); + } + } + } + actual.sort(); + assert_eq!(actual, expected, "{location}: each placement embeds its own visibility state"); + assert!(Arc::ptr_eq(&original, &session.doc().unwrap().doc), "export must not edit the live document"); + } + + fn external(location: &str) { + let (mut session, _, ids) = fixture(location); + // Each choice alone must reject the export, including a legacy nonempty hidden list. + for active in ids { + let original = session.doc().unwrap().doc.clone(); + let mut one = (*original).clone(); + one.update_placed_items( + |i| ids.contains(&i.id) && i.id != active, + |i| { + i.pdf_layer_override = false; + i.pdf_hidden_layers.clear(); + }, + ); + let mut single = Session::new(); + single.add_document(DocState::new(one, None)); + let before = single.doc().unwrap().doc.clone(); + let err = single.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string(); + assert!(err.contains("embedded images"), "{location}: {err}"); + assert!(Arc::ptr_eq(&before, &single.doc().unwrap().doc)); + } + Arc::make_mut(&mut session.doc_mut().unwrap().doc).update_placed_items( + |i| ids.contains(&i.id), + |i| { + i.pdf_layer_override = false; + i.pdf_hidden_layers.clear(); + }, + ); + session.execute("file.exportIdml", &json!({"embedImages": false})).unwrap(); + } + + // Duplicating a text frame that holds an anchored placed PDF leaves two placements sharing + // one ItemId (renumber gap, tracked separately). Export must still apply the choice to both. + #[test] + fn duplicated_frame_keeps_its_anchored_pdf_choice_in_idml() { + let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap(); + let control = ItemId(placed["id"].as_u64().unwrap()); + let (frame, chosen) = { + let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut item = doc.item(control).unwrap().clone(); + item.id = ItemId(doc.alloc()); + item.pdf_hidden_layers = vec!["Blue".into()]; + let chosen = item.id; + let layer = doc.default_layer(); + let (frame, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() }); + (frame, chosen) + }; + s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let with_choice: Vec<_> = doc.placed_items().into_iter().filter(|i| i.has_pdf_layer_override()).map(|i| i.id).collect(); + assert_eq!(with_choice, [chosen, chosen], "both placements share one id"); + assert!(doc.check().unwrap_err().to_string().contains(&format!("duplicate item id {chosen}"))); + let result = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&base64_decode(result["base64"].as_str().unwrap())).unwrap(); + let mut layers: Vec<_> = + imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).map(|a| designcraft_render::pdf_layers(&a.data)).collect(); + layers.sort(); + assert_eq!(layers, vec![states(false, true), states(true, false)]); + } + + // Shared anchored ids are a pre-existing duplication gap. Conflicting choices must not + // silently select one variant; this fixture changes only the copied anchor's choice. + #[test] + fn shared_id_with_divergent_pdf_choices_is_rejected() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap(); + let placed = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap(); + let control = ItemId(placed["id"].as_u64().unwrap()); + let frame = { + let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let mut item = doc.item(control).unwrap().clone(); + item.id = ItemId(doc.alloc()); + item.pdf_hidden_layers = vec!["Blue".into()]; + let layer = doc.default_layer(); + let (frame, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap(); + doc.story_mut(sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() }); + frame + }; + s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap(); + // Change ONLY the copy in the newest story to explicit all-on. + { + let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let (_, st) = doc.stories.iter_mut().filter(|(_, st)| !st.objects.is_empty()).last().unwrap(); + let o = &mut Arc::make_mut(st).objects[0]; + let it = &mut Arc::make_mut(o).item; + it.pdf_hidden_layers.clear(); + it.pdf_layer_override = true; + } + let before = s.doc().unwrap().doc.clone(); + let chosen: Vec<_> = before.placed_items().into_iter().filter(|i| i.has_pdf_layer_override()).collect(); + assert_eq!(chosen.len(), 2); + assert_eq!(chosen[0].id, chosen[1].id); + assert_eq!(chosen[0].graphic().unwrap().asset, chosen[1].graphic().unwrap().asset); + assert_ne!(chosen[0].pdf_hidden_layers, chosen[1].pdf_hidden_layers); + let err = s.execute("file.exportIdml", &json!({})).unwrap_err().to_string(); + assert!(err.contains("placements sharing id") && err.contains("different sources or layer choices"), "{err}"); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + + // Cross-document paste: the pasted frame's anchored placement keeps its source id, + // which can collide with an unrelated placement in the destination. + #[test] + fn shared_id_with_different_pdf_assets_is_rejected() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap(); + let a = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "a.pdf"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.layerOptions", &json!({"ids": [a], "hidden": ["Blue"]})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(f)).unwrap().text_frame().unwrap().story; + s.execute("anchored.insert", &json!({"ids": [a], "story": sid.0, "pos": 0})).unwrap(); + s.execute("selection.set", &json!({"ids": [f]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + // A different, unrelated PDF placement in the destination (no choice). + let b = s + .execute("file.place", &json!({"base64": base64_encode(&super::super::pdflayers::tests::two_layer_pdf()), "name": "b.pdf"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(a, b, "fresh documents allocate the same first placement id"); + let before = s.doc().unwrap().doc.clone(); + let graphics: Vec<_> = before.placed_items().into_iter().filter(|i| i.graphic().is_some()).collect(); + assert_eq!(graphics.len(), 2); + assert_eq!(graphics[0].id, graphics[1].id); + assert_ne!(graphics[0].graphic().unwrap().asset, graphics[1].graphic().unwrap().asset); + assert!(!graphics[0].has_pdf_layer_override(), "destination placement uses file defaults"); + assert!(graphics[1].has_pdf_layer_override()); + let err = s.execute("file.exportIdml", &json!({})).unwrap_err().to_string(); + assert!(err.contains("placements sharing id") && err.contains("different sources or layer choices"), "{err}"); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + } + + #[test] + fn nested_idml_cell_variants_are_applied() { + prepared("cell"); + } + #[test] + fn nested_idml_footnote_variants_are_applied() { + prepared("footnote"); + } + // Endnote structures/artwork are not serialized by IDML yet. Check preparation without + // mistaking a correct export-only variant for endnote-artwork serialization support. + #[test] + fn nested_idml_endnote_variants_are_prepared() { + prepared("endnote"); + } + #[test] + fn nested_idml_cell_embedded_choices() { + embedded("cell"); + } + #[test] + fn nested_idml_footnote_embedded_choices() { + embedded("footnote"); + } + #[test] + fn nested_idml_cell_external_choices_are_rejected() { + external("cell"); + } + #[test] + fn nested_idml_footnote_external_choices_are_rejected() { + external("footnote"); + } + #[test] + fn nested_idml_endnote_external_choices_are_rejected() { + external("endnote"); + } +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index d67f469f0..5ec6abb4d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,2536 +6,17 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; use crate::{Result, Session}; -/// A start page number parameter: a whole number in 1..=[`designcraft_doc::MAX_PAGE_NUMBER`]. -fn start_number(cmd: &str, key: &str, v: &Value) -> Result { - v.as_u64() - .and_then(|n| u32::try_from(n).ok()) - .filter(|n| (1..=designcraft_doc::MAX_PAGE_NUMBER).contains(n)) - .ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number from 1 to {}", designcraft_doc::MAX_PAGE_NUMBER))) -} - -/// Each page's margin box, by spread and page id. -fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { - d.spread_refs() - .filter_map(|r| d.spread(r).map(|sp| (r, sp))) - .flat_map(|(r, sp)| sp.pages.iter().map(move |pg| (r, pg.id, pg.margin_rect()))) - .collect() -} - -/// Layout Adjustment: objects on each page move and resize from its old margin box to its new -/// one (content keeps its size; frames that auto-fit refit). -fn adjust_layout(d: &mut designcraft_doc::Document, before: &[(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)]) { - use designcraft_geom::Affine; - let after = margin_boxes(d); - for (r, pid, old) in before { - let Some((_, _, new)) = after.iter().find(|(r2, p2, _)| r2 == r && p2 == pid) else { continue }; - if old == new || old.width() < 1e-6 || old.height() < 1e-6 { - continue; - } - let m = Affine::translate((new.x0, new.y0)) - * Affine::scale_non_uniform(new.width() / old.width(), new.height() / old.height()) - * Affine::translate((-old.x0, -old.y0)); - let Some(sp) = d.spread(*r) else { continue }; - // The page's objects (by centre, measured against the old boxes). - let ids: Vec = sp - .items - .iter() - .filter(|it| { - let c = it.bounds().center(); - // Pages share a spread side by side: the margin box's page is the one under the centre's x. - before - .iter() - .filter(|(r2, _, _)| r2 == r) - .min_by(|a, b| dist(a.2, c.x).total_cmp(&dist(b.2, c.x))) - .is_some_and(|(_, p2, _)| p2 == pid) - }) - .map(|it| it.id) - .collect(); - for id in ids { - let Some(it) = d.item_mut(id) else { continue }; - if matches!(it.content, designcraft_doc::Content::Group { .. }) { - it.xf = m * it.xf; - } else { - let inner = it.xf.inverse() * m * it.xf; - it.path.transform(inner); - if let Some(w) = &mut it.wrap.path { - w.transform(inner); - } - let ib = it.inner_bounds(); - if let designcraft_doc::Content::Graphic(g) = &mut it.content { - // Content keeps its size but moves with the frame; auto-fit refits. - let o = inner * designcraft_geom::Point::ZERO; - g.xf = Affine::translate(o.to_vec2()) * g.xf; - if let Some(xf) = g.fitted(ib, g.auto_fit) { - g.xf = xf; - } - } - } - } - } -} - -/// Horizontal distance from `x` to a margin box (0 inside). -fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { - if x < r.x0 { - r.x0 - x - } else if x > r.x1 { - x - r.x1 - } else { - 0.0 - } -} - -/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page. -fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { - let width_before = pg.margin_rect().width(); - match p.get("margins") { - Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)), - Some(v @ Value::Object(_)) => { - if let Ok(m) = serde_json::from_value(v.clone()) { - pg.margins = m; - } - } - _ => {} - } - scale_columns(pg, width_before); - // A new count or gutter re-divides the columns evenly: dragged column positions are what - // the new value replaces. The dialog always sends both, so an unchanged value keeps them. - if let Some(c) = p.get("columns").and_then(Value::as_u64) { - let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216); - if c != pg.columns.count { - pg.columns.count = c; - pg.columns.positions = None; - } - } - if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) { - let g = g.max(0.0); - if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE { - pg.columns.gutter = g; - pg.columns.positions = None; - } - } -} - -/// Dragged column positions are measured across the margin box. When the box was -/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set -/// that doesn't scale into a valid one goes back to the even split rather than stay hidden. -fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) { - let width = pg.margin_rect().width(); - if (width - width_before).abs() <= GUTTER_TOLERANCE { - return; - } - let Some(positions) = pg.columns.positions.as_mut() else { return }; - let factor = width / width_before; - if factor.is_finite() && factor > 0.0 { - positions.iter_mut().for_each(|p| *p *= factor); - // The ends sit exactly on the margins whatever the rounding. - if let Some(first) = positions.first_mut() { - *first = 0.0; - } - if let Some(last) = positions.last_mut() { - *last = width; - } - } - if pg.columns.valid_positions(width).is_none() { - pg.columns.positions = None; - } -} - -/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box. -fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) { - let before = pg.margin_rect().width(); - pg.width = width; - scale_columns(pg, before); -} - -/// How far a gutter may differ from the page's and still count as unchanged, in points. The -/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt -/// off), so an OK that leaves the field alone sends back a slightly different number. -const GUTTER_TOLERANCE: f64 = 0.01; - -pub fn specs() -> Vec { - vec![ - cmd!( - "layout.pages.insert", - "Insert Pages…", - ["Layout", "Pages"], - Some("Cmd+Shift+P"), - "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", - has_doc, - |s, p| { - const ID: &str = "layout.pages.insert"; - // A count that is given but is not a number of pages used to insert one page: - // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped. - let count = match p.get("count").filter(|v| !v.is_null()) { - None => 1, - Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) { - Some(n) => n, - None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))), - }, - }; - let st = s.doc()?; - let n = st.doc.page_count(); - // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` - // pins deliberately. - let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); - let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); - s.edit(|d, _| { - let ids = d.insert_pages(Some(after), count, parent)?; - Ok(json!({"pages": ids.len(), "total": d.page_count()})) - }) - } - ), - cmd!( - "layout.rotateSpreadView", - "Rotate Spread", - [], - None, - "{spread?: index (0), angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn one spread on screen (output is unaffected)", - has_doc, - |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let angle = match p.get("angle") { - None => 90, - Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?, - }; - if angle % 90 != 0 { - return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees")); - } - s.edit(|d, _| { - let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?; - let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?; - pg.view_rotation = if angle == 0 { 0 } else { ((pg.view_rotation as i64 + angle.div_euclid(90)).rem_euclid(4)) as u8 }; - Ok(json!({"rotation": pg.view_rotation as u32 * 90})) - }) - } - ), - cmd!("layout.pages.delete", "Delete Pages", ["Layout", "Pages"], None, "{pages: [index]}", has_doc, |s, p| { - let pages: Vec = p - .get("pages") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) - .unwrap_or_default(); - s.edit(|d, sel| { - d.delete_pages(&pages)?; - sel.items.retain(|i| d.item(*i).is_some()); - sel.text = sel.text.filter(|t| d.story(t.story).is_some()); - ok() - }) - }), - cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| { - let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize; - s.edit(|d, _| { - d.move_page(from, to)?; - ok() - }) - }), - cmd!( - "layout.pages.duplicateSpread", - "Duplicate Spread", - ["Layout", "Pages"], - None, - "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", - has_doc, - |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); - let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); - s.edit(|d, _| { - let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; - // The copies land at `to`..`to + n` of the document with them in it. - if to.is_some_and(|t| t > d.page_count()) { - return Err(bad("layout.pages.duplicateSpread", "no such page")); - } - let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; - let parent = sp.pages.first().and_then(|p| p.parent); - d.insert_pages(Some(after), sp.pages.len(), parent)?; - // The copies are the pages they copy: size, margins, columns, guides, liquid rule. - for (k, src) in sp.pages.iter().enumerate() { - let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; - let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); - let pg = &mut nsp.pages[npi]; - let dx = pg.x - src.x; - // A copy on the other side of the spine has its dragged columns mirrored. - let columns = pg.columns_from(src); - (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid); - pg.guides = src.guides.clone(); - for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { - g.position += dx; - } - nsp.relayout(); - } - // Copy items onto the new spread(s) at the same positions. - let src = d.clone(); - let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); - let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; - super::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; - let first = after + 1; - let Some(to) = to else { return Ok(json!({"page": first})) }; - // Moving a page shifts the ones between; order the moves so each copy keeps - // its place among the others. - let n = sp.pages.len(); - if first < to { - for k in (0..n).rev() { - d.move_page(first + k, to + k)?; - } - } else { - for k in 0..n { - d.move_page(first + k, to + k)?; - } - } - Ok(json!({"page": to})) - }) - } - ), - cmd!( - "layout.pages.applyParent", - "Apply Parent to Pages…", - ["Layout", "Pages"], - None, - "{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}", - has_doc, - |s, p| { - let pages: Vec = p - .get("pages") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) - .unwrap_or_default(); - let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None); - s.edit(|d, _| { - d.apply_parent(&pages, parent)?; - ok() - }) - } - ), - cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| { - let name = str_param(p, "name").unwrap_or("Parent").to_string(); - s.edit(|d, _| { - let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix()); - let src = d.page(0).cloned(); - let (columns, margins) = - src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins)); - let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins); - // Dragged column guides carry over to the parent pages they still fit, mirrored - // on the parent page across the spine from the first page. - if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) { - for pg in &mut std::sync::Arc::make_mut(sp).pages { - let columns = pg.columns_from(src); - if columns.valid_positions(pg.margin_rect().width()).is_some() { - pg.columns.positions = columns.positions; - } - } - } - Ok(json!({"id": id.0, "prefix": prefix})) - }) - }), - cmd!( - "layout.parents.delete", - "Delete Parent", - [], - None, - "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items", - has_doc, - |s, p| { - let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?; - s.edit(|d, sel| { - d.delete_parent(id)?; - *sel = Default::default(); - ok() - })?; - let st = s.doc_mut()?; - if st.doc.parents.is_empty() { - st.editing_parents = false; - } - ok() - } - ), - cmd!( - noundo "layout.parents.edit", - "Edit Parents", - [], - None, - "{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent", - has_doc, - |s, p| { - const ID: &str = "layout.parents.edit"; - let doc = s.doc()?.doc.clone(); - let parent = match p.get("parent") { - None | Some(Value::Null) => None, - v => match parent_ref(ID, &doc, v)? { - Some(Some(id)) => Some(id), - _ => return Err(bad(ID, "`parent` names a parent to show")), - }, - }; - let st = s.doc_mut()?; - let was = st.editing_parents; - let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was); - st.editing_parents = on; - st.selection = Default::default(); - st.bump_revision(); - // The parent to show: the one asked for, or the first when editing starts. - let index = match parent { - Some(id) => doc.parent_index(id), - None if !was && !doc.parents.is_empty() => Some(0), - None => None, - }; - let shown = index.filter(|_| on); - if let Some(i) = shown { - s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) }); - } - Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)})) - } - ), - cmd!( - "layout.parents.options", - "Parent Options…", - [], - None, - "{parent: id|\"B-Parent\"|\"B\", basedOn: id|\"A-Parent\"|\"A\"|null (= [None])} — the parent shows its base's items under its own, and so do the pages that use it → {parent, basedOn}", - has_doc, - parent_options - ), - cmd!( - "layout.marginsAndColumns", - "Margins and Columns…", - ["Layout"], - None, - "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins), parent? (a parent's prefix, label or index)} — document pages take these as their own margins; with `parent` the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own", - has_doc, - |s, p| { - let pages: Option> = - p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); - let parent = match p.get("parent") { - None | Some(Value::Null) => None, - Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?), - }; - let p = p.clone(); - let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); - s.edit(|d, _| { - let before = adjust.then(|| margin_boxes(d)); - if let Some(pi) = parent { - let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len()); - let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut); - if let Some(sp) = sp { - for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) { - if let Some(pg) = sp.pages.get_mut(qi) { - set_margins_columns(pg, &p); - } - } - } - d.sync_parent_margins(); - } else { - let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); - for abs in all { - let Some((si, pi)) = d.page_loc(abs) else { continue }; - let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue }; - set_margins_columns(pg, &p); - pg.local_margins = true; - } - } - if let Some(b) = before { - adjust_layout(d, &b); - } - ok() - }) - } - ), - cmd!( - "layout.moveColumnGuide", - "Move Column Guide", - [], - None, - "{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked", - column_guides_unlocked, - move_column_guide - ), - cmd!( - "layout.documentSetup", - "Document Setup…", - ["File"], - Some("Cmd+Alt+P"), - "{width?, height?, pages?: count, startPage?: n, facingPages?, binding?: leftToRight|rightToLeft, intent?: print|web|mobile, margins?: n | {top, bottom, inside, outside} (every page and parent page), bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size and margins)} → the document setup (margins: the first page's)", - has_doc, - |s, p| { - let p = p.clone(); - if p.as_object().is_none_or(|o| o.is_empty()) { - return Ok(document_setup(&s.doc()?.doc)); - } - let edges = |k: &str| -> Option<[f64; 4]> { - match p.get(k)? { - Value::Array(a) if a.len() == 4 => { - let v: Vec = a.iter().filter_map(Value::as_f64).map(|v| v.max(0.0)).collect(); - (v.len() == 4).then(|| [v[0], v[1], v[2], v[3]]) - } - v => v.as_f64().map(|b| [b.max(0.0); 4]), - } - }; - let (bleed, slug) = (edges("bleed"), edges("slug")); - for k in ["width", "height"] { - if let Some(v) = p.get(k) - && !v.as_f64().is_some_and(|x| x > 0.0 && x <= 15552.0) - { - return Err(bad("layout.documentSetup", format!("{k}: page size out of range (0 < size ≤ 216 in)"))); - } - } - let margins: Option = match p.get("margins") { - None => None, - Some(Value::Number(n)) => n.as_f64().map(Margins::uniform), - Some(v) => serde_json::from_value(v.clone()).ok(), - }; - if margins.is_some_and(|m| ![m.top, m.bottom, m.inside, m.outside].iter().all(|v| v.is_finite() && (0.0..=15552.0).contains(v))) { - return Err(bad("layout.documentSetup", "margins: 0 to 216 in")); - } - if p.get("margins").is_some() && margins.is_none() { - return Err(bad("layout.documentSetup", "margins: a number or {top, bottom, inside, outside}")); - } - let intent: Option = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()); - let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize); - let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); - s.edit(|d, _| { - let before = adjust.then(|| margin_boxes(d)); - if let Some(f) = p.get("facingPages").and_then(Value::as_bool) { - d.settings.facing_pages = f; - } - if let Some(b) = p.get("binding").and_then(Value::as_str) { - d.settings.right_to_left_binding = b == "rightToLeft"; - d.order_parent_pages(); - } - if let Some(b) = bleed { - d.settings.bleed = b; - } - if let Some(b) = slug { - d.settings.slug = b; - } - if let Some(i) = intent { - d.settings.intent = i; - } - if let Some(n) = p.get("startPage").map(|v| start_number("layout.documentSetup", "startPage", v)).transpose()? { - if !d.sections.iter().any(|x| x.start == 0) { - d.sections.insert( - 0, - designcraft_doc::Section { - start: 0, - start_number: None, - style: Default::default(), - prefix: String::new(), - marker: String::new(), - include_prefix: false, - }, - ); - } - if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) { - first.start_number = Some(n); - } - } - // Number of Pages: add at the end (with the last page's parent) or remove from the end. - if let Some(n) = pages { - let have = d.page_count(); - if n > have { - let parent = d.page_loc(have - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); - d.insert_pages(Some(have - 1), n - have, parent)?; - } else if n < have { - d.delete_pages(&(n..have).collect::>())?; - } - } - let w = p.get("width").and_then(Value::as_f64); - let h = p.get("height").and_then(Value::as_f64); - // Pages with a liquid rule re-flow their objects (Adjust Layout aside). - let liquid_before: Vec<(usize, usize, designcraft_geom::Rect)> = if adjust { - vec![] - } else { - d.spreads - .iter() - .enumerate() - .flat_map(|(si, sp)| { - sp.pages.iter().enumerate().filter(|(_, pg)| !pg.liquid.is_off()).map(move |(pi, pg)| (si, pi, pg.bounds())) - }) - .collect() - }; - if w.is_some() || h.is_some() { - d.settings.page_width = w.unwrap_or(d.settings.page_width); - d.settings.page_height = h.unwrap_or(d.settings.page_height); - for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { - let sp = std::sync::Arc::make_mut(sp); - for pg in &mut sp.pages { - set_page_width(pg, d.settings.page_width); - pg.height = d.settings.page_height; - } - sp.relayout(); - } - } - if let Some(m) = margins { - for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { - for pg in &mut std::sync::Arc::make_mut(sp).pages { - pg.margins = m; - } - } - // Pages that now match their parent page follow its later changes; local - // columns stay local. - d.unmark_matching_local_margins(); - d.sync_parent_margins(); - } - d.repaginate(); - if let Some(b) = before { - adjust_layout(d, &b); - } - for (si, pi, old) in liquid_before { - super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, old); - } - Ok(document_setup(d)) - }) - } - ), - cmd!( - "layout.pages.toSpread", - "Add Page to Spread", - ["Layout", "Pages"], - None, - "{page (1-based), spread (0-based)} — the page joins that spread (up to 10 pages; the spread stops shuffling)", - has_doc, - |s, p| { - let page = - p.get("page").and_then(Value::as_u64).filter(|n| *n >= 1).ok_or_else(|| bad("layout.pages.toSpread", "missing page"))? as usize; - let spread = p.get("spread").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.toSpread", "missing spread"))? as usize; - s.edit(|d, _| { - d.page_to_spread(page - 1, spread)?; - Ok(json!({"spreads": d.spreads.len()})) - }) - } - ), - cmd!( - "layout.spreadShuffle", - "Allow Selected Spread to Shuffle", - ["Layout", "Pages"], - None, - "{spread (0-based), allow: bool} — off keeps the spread's pages together when pages are added or removed", - has_doc, - |s, p| { - let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; - let allow = p.get("allow").and_then(Value::as_bool).unwrap_or(true); - s.edit(|d, _| { - let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.spreadShuffle", "no such spread"))?; - std::sync::Arc::make_mut(sp).allow_shuffle = allow; - if allow { - d.repaginate(); - } - ok() - }) - } - ), - cmd!( - "layout.pageSize", - "Page Size", - [], - None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", - has_doc, - page_size - ), - cmd!( - "layout.pageOptions", - "Page Options", - [], - None, - "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", - has_doc, - page_options - ), - cmd!( - noundo "layout.selectPage", - "Select Page", - [], - None, - "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", - has_doc, - |s, p| { - let n = s.doc()?.doc.page_count(); - let page = match p.get("page") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), - }; - let ids: Vec = { - let d = &s.doc()?.doc; - p.get("ids") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) - .unwrap_or_default() - }; - let st = s.doc_mut()?; - if page.is_some() { - st.selection = designcraft_doc::Selection::items(ids); - } - st.selection.page = page; - st.bump_revision(); - Ok(json!({"page": page.map(|p| p + 1)})) - } - ), - cmd!( - "layout.section", - "Numbering & Section Options…", - ["Layout"], - None, - "{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits|kanji, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", - has_doc, - |s, p| { - let edit = SectionEdit::parse("layout.section", s, p)?; - s.edit(|d, _| { - edit.apply(d); - Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) - }) - } - ), - cmd!( - "layout.chapterNumbering", - "Document Chapter Numbering", - [], - None, - "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting", - has_doc, - |s, p| { - if p.as_object().is_none_or(|o| o.is_empty()) { - return chapter_numbering(s); - } - let edit = ChapterEdit::parse("layout.chapterNumbering", p)?; - s.edit(|d, _| { - edit.apply(d); - ok() - })?; - chapter_numbering(s) - } - ), - cmd!( - "layout.numberingOptions", - "Apply Numbering & Section Options", - [], - None, - "{page, …the settings as layout.section, chapter?: {…the settings as layout.chapterNumbering}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes", - has_doc, - |s, p| { - let section = SectionEdit::parse("layout.numberingOptions", s, p)?; - let chapter = match p.get("chapter") { - Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?), - None => None, - }; - s.edit(|d, _| { - section.apply(d); - if let Some(chapter) = &chapter { - chapter.apply(d); - } - Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>(), "chapter": d.chapter_label()})) - }) - } - ), - cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?); - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?; - s.edit(|d, _| { - let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; - let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?; - let l = d.layers.remove(i); - d.layers.insert(to, l); - ok() - }) - }), - cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let st = s.doc_mut()?; - let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); - st.selection = designcraft_doc::Selection::items(ids); - st.bump_revision(); - ok() - }), - cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { - let name = str_param(p, "name").map(str::to_string); - let r = s.edit(|d, _| { - let id = LayerId(d.alloc()); - let n = d.layers.len(); - let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1)); - d.layers.insert( - 0, - Layer { - id, - name, - color: LAYER_COLORS[n % LAYER_COLORS.len()].1, - visible: true, - locked: false, - printable: true, - show_guides: true, - suppress_wrap_when_hidden: false, - }, - ); - Ok(id) - })?; - s.doc_mut()?.active_layer = r; - Ok(json!({"id": r.0})) - }), - cmd!( - "layer.set", - "Layer Options…", - [], - None, - "{id, name?, color?: [r,g,b] | layer colour name (\"Light Blue\", \"Red\", …), visible?, locked?, printable?, showGuides?, suppressWrapWhenHidden?} — names are unique and not empty", - has_doc, - set_layer - ), - cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - s.edit(|d, sel| { - if d.layers.len() < 2 { - return Err(bad("layer.delete", "a document needs at least one layer")); - } - let doomed: Vec<_> = - d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect(); - for i in doomed { - let _ = d.remove_item(i); - } - d.layers.retain(|l| l.id != id); - *sel = Default::default(); - ok() - }) - }), - cmd!( - "layer.merge", - "Merge Layers", - [], - None, - "{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted", - has_doc, - |s, p| { - let ids: Vec = - p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default(); - let into = p - .get("into") - .and_then(Value::as_u64) - .map(LayerId) - .or_else(|| ids.first().copied()) - .ok_or_else(|| bad("layer.merge", "no layers"))?; - s.edit(|d, _| { - if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) { - return Err(bad("layer.merge", "no such layer")); - } - let gone: Vec = ids.iter().copied().filter(|l| *l != into).collect(); - for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { - if !sp.items.iter().any(|i| gone.contains(&i.layer)) { - continue; - } - for it in &mut std::sync::Arc::make_mut(sp).items { - if gone.contains(&it.layer) { - std::sync::Arc::make_mut(it).layer = into; - } - } - } - d.layers.retain(|l| !gone.contains(&l.id)); - Ok(json!({"merged": gone.len()})) - }) - } - ), - cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| { - s.edit(|d, _| { - let used: std::collections::HashSet = - d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect(); - let before = d.layers.len(); - if used.is_empty() { - // Nothing anywhere: keep the first layer. - d.layers.truncate(1); - } else { - d.layers.retain(|l| used.contains(&l.id)); - } - Ok(json!({"deleted": before - d.layers.len()})) - }) - }), - cmd!( - "layer.others", - "Hide/Lock Others", - [], - None, - "{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`", - has_doc, - |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock")); - s.edit(|d, _| { - for l in &mut d.layers { - if show { - l.visible = true; - } - if unlock { - l.locked = false; - } - if l.id != id { - if hide { - l.visible = false; - } - if lock { - l.locked = true; - } - } - } - ok() - }) - } - ), - cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| { - let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); - let st = s.doc_mut()?; - if st.doc.layer(id).is_none() { - return Err(bad("layer.activate", "no such layer")); - } - st.active_layer = id; - st.bump_revision(); - ok() - }), - ] -} - -/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column -/// Guides is off. -fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - match s.active() { - Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()), - _ => Ok(()), - } -} - -/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`. -fn move_column_guide(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "layout.moveColumnGuide"; - let index = |key: &str| -> Result { - let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?; - v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer"))) - }; - let (page, gutter) = (index("page")?, index("gutter")?); - let position = - p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?; - let r = match p.get("parent") { - None | Some(Value::Null) => super::spread_param(p, CMD)?, - Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?), - }; - s.edit(|d, _| { - if d.settings.column_guides_locked { - return Err(bad(CMD, "column guides are locked")); - } - let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?; - let m = pg.margin_rect(); - let count = pg.columns.count; - if gutter.checked_add(1).is_none_or(|g| g >= count as usize) { - return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1)))); - } - let positions = - pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?; - let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v); - pg.columns.positions = Some(positions.clone()); - match r { - designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true, - designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(), - } - Ok(json!({"positions": positions, "left": left})) - }) -} - -/// A parent spread named by prefix, label (`A-Parent`) or index. -fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { - match v { - Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < d.parents.len()), - Value::String(s) => d.parents.iter().position(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)), - _ => None, - } -} - -/// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or -/// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent); -/// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never -/// clears a page's parent. -fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result>> { - let Some(v) = v else { return Ok(None) }; - match v { - Value::Null => Ok(Some(None)), - Value::String(s) if s == "[None]" => Ok(Some(None)), - Value::Number(n) => { - let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?; - d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}"))) - } - Value::String(s) => { - let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec { - d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect() - }; - let mut hits = by(&|i| i.label() == *s); - if hits.is_empty() { - hits = by(&|i| i.prefix == *s); - } - match hits.as_slice() { - [id] => Ok(Some(Some(*id))), - [] => Err(bad(cmd, format!("no parent named `{s}`"))), - _ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))), - } - } - _ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")), - } -} - -/// Layout › Pages › Parent Options: Based on Parent. A base that would make a cycle is refused. -fn parent_options(s: &mut crate::Session, p: &Value) -> Result { - const CMD: &str = "layout.parents.options"; - let d = &s.doc()?.doc; - let Some(Some(id)) = parent_ref(CMD, d, p.get("parent"))? else { return Err(bad(CMD, "`parent` names the parent to change")) }; - let pi = d.parent_index(id).ok_or_else(|| bad(CMD, format!("no parent with id {}", id.0)))?; - let base = parent_ref(CMD, d, p.get("basedOn"))?.ok_or_else(|| bad(CMD, "missing `basedOn` (a parent, or null for [None])"))?; - let label = |d: &designcraft_doc::Document, i: usize| d.parents.get(i).and_then(|x| x.parent.as_ref()).map(|i| i.label()); - if let Some(b) = base - && d.parent_base_cycles(pi, b) - { - let (name, b) = (label(d, pi).unwrap_or_default(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default()); - return Err(bad(CMD, format!("{name} cannot be based on {b}: that would make a cycle"))); - } - let out = json!({"parent": label(d, pi), "basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i))}); - if d.parents.get(pi).and_then(|x| x.parent.as_ref()).and_then(|i| i.based_on) == base { - return Ok(out); - } - s.edit(|d, _| { - let info = d.parents.get_mut(pi).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()).ok_or_else(|| bad(CMD, "no such parent"))?; - info.based_on = base; - Ok(out) - }) -} - -#[allow(dead_code)] -fn _r(_: Result<()>) {} - -/// Layout › Page Size (the Page tool): resize pages one by one. Left pages grow to the left -/// (the spine stays put); objects move with their page. -fn page_size(s: &mut crate::Session, p: &Value) -> Result { - let pages: Vec = - p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); - let n = s.doc()?.doc.page_count(); - if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { - return Err(bad("layout.pageSize", "give `pages` (1-based)")); - } - let (mut w, mut h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); - if let Some(name) = str_param(p, "preset") { - let pr = designcraft_doc::build::PRESETS - .iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) - .ok_or_else(|| bad("layout.pageSize", format!("no preset `{name}`")))?; - w = Some(pr.width); - h = Some(pr.height); - } - let anchor = match p.get("anchor") { - None | Some(Value::Null) => 0, - Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, - }; - s.edit(|d, _| { - resize_pages_from(d, &pages, w, h, anchor); - Ok(json!({"pages": pages.len()})) - }) -} - -fn page_options(s: &mut crate::Session, p: &Value) -> Result { - let pages: Vec = - p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); - let n = s.doc()?.doc.page_count(); - if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { - return Err(bad("layout.pageOptions", "give `pages` (1-based)")); - } - let overlay = p.get("showParentOverlay").and_then(Value::as_bool); - s.edit(|d, _| { - for page in &pages { - let Some((si, pi)) = d.page_loc(page - 1) else { continue }; - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; - if let Some(v) = overlay { - pg.show_parent_overlay = v; - } - } - Ok(json!({"pages": pages.len()})) - }) -} - -/// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule -/// re-flow their objects by it. -pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { - resize_pages_from(d, pages, w, h, 0); -} - -/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: -/// the page grows away from it, so objects keep their place relative to it. -pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { - let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); - { - for page in pages { - let Some((si, pi)) = d.page_loc(page - 1) else { continue }; - let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); - let old: Vec = sp.pages.iter().map(|x| x.x).collect(); - let old_bounds = sp.pages[pi].bounds(); - // The page's own objects (outer pages take the pasteboard beside them). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); - let own: Vec = sp - .items - .iter() - .enumerate() - .filter(|(_, it)| { - let c = it.bounds().center().x; - c >= lo && c < hi - }) - .map(|(i, _)| i) - .collect(); - let pg = &mut sp.pages[pi]; - let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); - let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); - if let Some(w) = w { - set_page_width(pg, w.max(1.0)); - } - if let Some(h) = h { - pg.height = h.max(1.0); - } - let left = pg.side == designcraft_doc::PageSide::Left; - sp.relayout(); - // Keep the spine: shift the whole spread back when a left page grows. - if left && dw != 0.0 { - for q in &mut sp.pages { - q.x -= dw; - } - } - let new: Vec = sp.pages.iter().map(|x| x.x).collect(); - // Objects follow their page. - let shifts: Vec<(f64, f64, f64)> = - sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); - for it in &mut sp.items { - let cx = it.bounds().center().x; - if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) - && *dx != 0.0 - { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; - } - } - // Page guides move with their page. - for (k, q) in sp.pages.iter_mut().enumerate() { - let dx = new[k] - old[k]; - if dx != 0.0 { - for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { - g.position += dx; - } - } - } - // Growing away from the anchor: the page's objects and guides keep their place - // relative to it. - let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); - if shift != designcraft_geom::Vec2::ZERO { - for i in &own { - if let Some(it) = sp.items.get_mut(*i) { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate(shift) * it.xf; - } - } - for g in &mut sp.pages[pi].guides { - g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; - } - } - // Liquid rules: objects were carried along with the page's left edge (and the anchor). - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; - super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); - } - } -} - -/// File › Document Setup values. -fn document_setup(d: &designcraft_doc::Document) -> Value { - let st = &d.settings; - let start = d.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).unwrap_or(1); - json!({"width": st.page_width, "height": st.page_height, "pages": d.page_count(), "startPage": start, "facingPages": st.facing_pages, - "binding": if st.right_to_left_binding { "rightToLeft" } else { "leftToRight" }, "intent": st.intent, "bleed": st.bleed, "slug": st.slug, - "margins": d.page(0).map(|p| p.margins), "primaryTextFrame": st.primary_text_frame}) -} - -/// The largest chapter number (as for page numbers). -const MAX_CHAPTER: u64 = designcraft_doc::MAX_PAGE_NUMBER as u64; - -/// A `layout.section` edit, checked before the document changes. -struct SectionEdit { - /// Absolute index of the section's first page. - start: usize, - remove: bool, - /// Some(None): continue from the previous section. - start_number: Option>, - style: Option, - prefix: Option, - include_prefix: Option, - marker: Option, -} - -impl SectionEdit { - fn parse(cmd: &str, s: &Session, p: &Value) -> Result { - let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))?; - let n = s.doc()?.doc.page_count() as u64; - if page == 0 || page > n { - return Err(bad(cmd, format!("no page {page}"))); - } - let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string); - if prefix.as_ref().is_some_and(|x| x.chars().count() > 8 || x.contains(['+', ','])) { - return Err(bad(cmd, "section prefix: up to 8 characters, without + or ,")); - } - let start_number = match p.get("startNumber") { - None => None, - Some(Value::Null) => Some(None), - Some(v) => Some(Some(start_number(cmd, "startNumber", v)?)), - }; - let style = match p.get("style") { - None => None, - Some(v) => match serde_json::from_value(v.clone()) { - Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad(cmd, "symbols don't number pages")), - Ok(style) => Some(style), - Err(e) => return Err(bad(cmd, e.to_string())), - }, - }; - Ok(SectionEdit { - start: (page - 1) as usize, - remove: p.get("remove").and_then(Value::as_bool).unwrap_or(false), - start_number, - style, - prefix, - include_prefix: p.get("includePrefix").and_then(Value::as_bool), - marker: p.get("marker").and_then(Value::as_str).map(str::to_string), - }) - } - - fn apply(&self, d: &mut designcraft_doc::Document) { - let start = self.start; - // Page 1 always starts a section. - if self.remove && start != 0 { - d.sections.retain(|x| x.start != start); - // The pages after it follow the previous section's numbers again. - d.repaginate(); - return; - } - let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { - start, - start_number: None, - style: Default::default(), - prefix: String::new(), - marker: String::new(), - include_prefix: false, - }); - if let Some(n) = self.start_number { - sec.start_number = n; - } - if let Some(style) = self.style { - sec.style = style; - } - if let Some(prefix) = &self.prefix { - sec.prefix = prefix.clone(); - } - if let Some(on) = self.include_prefix { - sec.include_prefix = on; - } - if let Some(marker) = &self.marker { - sec.marker = marker.clone(); - } - d.sections.retain(|x| x.start != start); - d.sections.push(sec); - d.sections.sort_by_key(|x| x.start); - // Facing pages take their sides from their numbers: odd on the right. - d.repaginate(); - } -} - -/// A `layout.chapterNumbering` edit, checked before the document changes. -struct ChapterEdit { - style: Option, - source: Option, - start: Option, -} - -impl ChapterEdit { - fn parse(cmd: &str, p: &Value) -> Result { - let style = match p.get("style") { - Some(v) => match serde_json::from_value::(v.clone()) { - Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => return Err(bad(cmd, format!("unknown chapter style {v}"))), - Ok(style) => Some(style), - }, - None => None, - }; - let mut source = match p.get("source") { - Some(v) => Some( - serde_json::from_value::(v.clone()) - .map_err(|_| bad(cmd, format!("unknown chapter number source {v}")))?, - ), - None => None, - }; - let start = match p.get("start") { - Some(v) => { - Some(v.as_u64().filter(|n| (1..=MAX_CHAPTER).contains(n)).ok_or_else(|| bad(cmd, format!("chapter start: 1 to {MAX_CHAPTER}")))? - as u32) - } - None => None, - }; - // A start number alone means Start Chapter Numbering at. - if start.is_some() && source.is_none() { - source = Some(designcraft_doc::ChapterSource::UserDefined); - } - Ok(ChapterEdit { style, source, start }) - } - - fn apply(&self, d: &mut designcraft_doc::Document) { - if let Some(style) = self.style { - d.settings.chapter_style = style; - } - if let Some(source) = self.source { - d.settings.chapter_source = source; - } - if let Some(n) = self.start { - d.settings.chapter_number = n; - } - } -} - -/// The active document's chapter numbering, and the open book that holds it (what -/// `layout.chapterNumbering {}` reports, read without running a command). -pub fn chapter_numbering(s: &Session) -> Result { - let st = s.doc()?; - let set = &st.doc.settings; - let book = s - .book - .as_ref() - .filter(|b| st.path.as_deref().is_some_and(|p| b.documents.iter().any(|d| std::path::Path::new(d) == std::path::Path::new(p)))) - .map(|b| std::path::Path::new(&b.path).file_stem().map_or_else(|| b.path.clone(), |n| n.to_string_lossy().to_string())); - Ok( - json!({"chapterNumber": set.chapter_number.max(1), "label": st.doc.chapter_label(), "style": set.chapter_style, "source": set.chapter_source, "book": book}), - ) -} - -/// Layer Options: every field in one undo step; a bad name or colour changes nothing. -fn set_layer(s: &mut Session, p: &Value) -> Result { - const ID: &str = "layer.set"; - let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); - let name = match p.get("name") { - Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "a layer needs a name"))?.to_string()), - None => None, - }; - let color: Option<[u8; 3]> = match p.get("color") { - None => None, - Some(Value::String(n)) => Some( - LAYER_COLORS - .iter() - .find(|(k, _)| k.eq_ignore_ascii_case(n)) - .map(|(_, c)| *c) - .ok_or_else(|| bad(ID, format!("unknown layer colour `{n}`")))?, - ), - Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(ID, "color is [r,g,b] (0–255) or a layer colour name"))?), - }; - let flag = |k: &str| match p.get(k) { - None => Ok(None), - Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(ID, format!("`{k}` is true or false"))), - }; - let (visible, locked, printable) = (flag("visible")?, flag("locked")?, flag("printable")?); - let (guides, wrap) = (flag("showGuides")?, flag("suppressWrapWhenHidden")?); - s.edit(|d, _| { - if let Some(n) = &name - && d.layers.iter().any(|l| l.id != id && l.name == *n) - { - return Err(bad(ID, format!("a layer named `{n}` exists"))); - } - let l = d.layer_mut(id).ok_or_else(|| bad(ID, "no such layer"))?; - if let Some(n) = name { - l.name = n; - } - if let Some(c) = color { - l.color = c; - } - for (slot, v) in [ - (&mut l.visible, visible), - (&mut l.locked, locked), - (&mut l.printable, printable), - (&mut l.show_guides, guides), - (&mut l.suppress_wrap_when_hidden, wrap), - ] { - if let Some(v) = v { - *slot = v; - } - } - ok() - }) -} - -#[cfg(test)] -mod setup_tests { - use serde_json::json; - - #[test] - fn chapter_numbering_sets_style_and_start() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("layout.chapterNumbering", &json!({})).unwrap(); - assert_eq!(r, json!({"chapterNumber": 1, "label": "1", "style": "arabic", "source": "automatic", "book": null})); - let r = s.execute("layout.chapterNumbering", &json!({"start": 4, "style": "upperRoman"})).unwrap(); - assert_eq!((&r["label"], &r["source"]), (&json!("IV"), &json!("userDefined")), "a start number means Start Chapter Numbering at"); - s.execute("variables.define", &json!({"name": "Chapter", "type": "chapterNumber"})).unwrap(); - let d = &s.doc().unwrap().doc; - let chapter = d.variable("Chapter").unwrap(); - assert_eq!(d.variable_value(chapter, Some(0)).as_deref(), Some("IV"), "the Chapter Number variable uses the style"); - for bad in [json!({"style": "symbols"}), json!({"start": 0}), json!({"start": 100_000}), json!({"source": "nextBook"})] { - assert!(s.execute("layout.chapterNumbering", &bad).is_err(), "{bad}"); - } - s.execute("layout.chapterNumbering", &json!({"source": "sameAsPrevious"})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["source"], "userDefined", "undoable"); - } - - #[test] - fn document_setup_sets_every_pages_margins_in_one_step() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 3, "margins": 36})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text"})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({})).unwrap(); - assert_eq!(r["margins"], json!({"top": 36.0, "bottom": 36.0, "inside": 36.0, "outside": 36.0})); - let m = json!({"top": 72, "bottom": 54, "inside": 72, "outside": 72}); - let r = s.execute("layout.documentSetup", &json!({"margins": m, "bleed": 9, "adjustLayout": true})).unwrap(); - assert_eq!(r["margins"]["bottom"], 54.0); - let d = &s.doc().unwrap().doc; - assert!( - d.spreads.iter().chain(&d.parents).flat_map(|sp| &sp.pages).all(|pg| pg.margins.top == 72.0 && pg.margins.bottom == 54.0), - "every page and parent page" - ); - let b = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap().bounds(); - assert!((b.y0 - 72.0).abs() < 1e-6, "Adjust Layout: the frame follows the margins ({b:?})"); - // One step: undo brings back the margins and the bleed. - s.execute("edit.undo", &json!({})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({})).unwrap(); - assert_eq!((r["margins"]["top"].clone(), r["bleed"][0].clone()), (json!(36.0), json!(0.0))); - for bad in [ - json!({"width": 0}), - json!({"height": -5}), - json!({"width": 1e9}), - json!({"width": "wide"}), - json!({"margins": -1}), - json!({"margins": {"top": 1}}), - ] { - assert!(s.execute("layout.documentSetup", &bad).is_err(), "{bad}"); - } - assert_eq!(s.execute("layout.documentSetup", &json!({})).unwrap()["width"], 612.0, "refused values change nothing"); - } - - #[test] - fn document_setup_pages_start_bleed_slug() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({})).unwrap(); - assert_eq!(r["pages"], 2); - assert_eq!(r["startPage"], 1); - let r = s.execute("layout.documentSetup", &json!({"pages": 5, "startPage": 2, "bleed": 9, "slug": [0, 18, 0, 0], "intent": "web"})).unwrap(); - assert_eq!(r["pages"], 5); - assert_eq!(r["startPage"], 2); - assert_eq!(r["bleed"], json!([9.0, 9.0, 9.0, 9.0])); - assert_eq!(r["slug"], json!([0.0, 18.0, 0.0, 0.0])); - assert_eq!(r["intent"], "web"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_name(0), "2"); - assert_eq!(d.page(0).unwrap().side, designcraft_doc::PageSide::Left); - assert_eq!(d.spreads[0].pages.len(), 2, "pages 2–3 form the first spread"); - let r = s.execute("layout.documentSetup", &json!({"pages": 3})).unwrap(); - assert_eq!(r["pages"], 3); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn right_to_left_binding_reverses_spreads() { - use designcraft_doc::PageSide::{Left, Right}; - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - // A frame on page 2 stays on page 2. - let w = s.doc().unwrap().doc.settings.page_width; - let f = s.execute("frame.create", &json!({"rect": [w + 72.0, 72, w + 200.0, 200], "content": "text", "text": "two"})).unwrap(); - let r = s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); - assert_eq!(r["binding"], "rightToLeft"); - let d = &s.doc().unwrap().doc; - let shape: Vec> = d.spreads.iter().map(|sp| sp.pages.iter().map(|p| (p.side, p.x)).collect()).collect(); - // Page 1 alone on the left; pages 2–3 with 2 on the right; page 4 alone on the right. - assert_eq!(shape, vec![vec![(Left, 0.0)], vec![(Right, w), (Left, 0.0)], vec![(Right, 0.0)]]); - let (si, _) = d.page_loc(1).unwrap(); - assert_eq!(si, 1); - let it = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap(); - assert_eq!(d.spreads[si].page_at_x(it.bounds().center().x), Some(0), "the frame is still on page 2"); - assert!(it.bounds().x0 >= w, "page 2 is the right page now"); - d.check().unwrap(); - // And back. - s.execute("layout.documentSetup", &json!({"binding": "leftToRight"})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); - } - - /// Bound right to left, a parent spread lists its right page first; each document page still - /// shows the parent page on its own side, at its own position, also after an IDML round trip. - #[test] - fn right_to_left_binding_shows_the_parent_page_of_the_same_side() { - use designcraft_doc::PageSide::{Left, Right}; - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); - let w = s.doc().unwrap().doc.settings.page_width; - let parent = json!({"kind": "parent", "index": 0}); - for (rect, text) in [(json!([72, 36, 200, 60]), "left head"), (json!([w + 72.0, 36, w + 200.0, 60]), "right head")] { - s.execute("frame.create", &json!({"spread": parent, "rect": rect, "content": "text", "text": text})).unwrap(); - } - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.parents[0].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Right, w), (Left, 0.0)]); - let docs = [(*d).clone(), designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap()]; - for d in docs { - let mut s = crate::Session::new(); - s.add_document(crate::DocState::new(d, None)); - for (page, side, text) in [(0, Left, "left head"), (1, Right, "right head"), (2, Left, "left head"), (3, Right, "right head")] { - let r = s.execute("layout.overrideParentItems", &json!({"page": page})).unwrap(); - let d = &s.doc().unwrap().doc; - let ids = r["ids"].as_array().unwrap(); - assert_eq!(ids.len(), 1, "page {}", page + 1); - let it = d.item(designcraft_doc::ItemId(ids[0].as_u64().unwrap())).unwrap(); - let story = d.story(it.text_frame().unwrap().story).unwrap(); - assert_eq!((d.page(page).unwrap().side, story.text.trim_end()), (side, text), "page {}", page + 1); - assert_eq!(d.page_of_item(it.id), Some(page), "page {}", page + 1); - } - } - } - - /// Multi-column frames filled the left column first in a right-to-left document (#100): - /// stories made in a right-to-left-bound document start their columns on the right. - #[test] - fn new_stories_follow_the_binding_direction() { - use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; - let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); - for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); - s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); - s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); - let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - let st = s.doc().unwrap(); - assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); - let cs = s.cache.get(&st.doc, sid, None); - let fr = &cs.frames[0]; - let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); - assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); - assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); - // An empty frame given text content starts a story too. - let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); - s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); - let d = &s.doc().unwrap().doc; - let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); - } - } -} - -#[cfg(test)] -mod layer_tests { - use serde_json::json; - - #[test] - fn layer_move_reorders_and_rejects_bad_input() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let l1 = s.doc().unwrap().doc.layers[0].id.0; - let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); - let order = |s: &crate::Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::>(); - assert_eq!(order(&s), [l2, l1]); - for bad in [json!({"id": l1, "to": 2}), json!({"id": 999, "to": 0}), json!({"id": l1}), json!({"to": 0})] { - assert!(s.execute("layer.move", &bad).is_err(), "{bad}"); - assert_eq!(order(&s), [l2, l1]); - } - s.execute("layer.move", &json!({"id": l1, "to": 0})).unwrap(); - assert_eq!(order(&s), [l1, l2]); - } - - #[test] - fn merge_delete_unused_hide_and_lock_others() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let l1 = s.doc().unwrap().doc.layers[0].id.0; - let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); - let l3 = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); - s.execute("layer.activate", &json!({"id": l2})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layer.others", &json!({"id": l2, "hide": true, "lock": true})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.layers.iter().all(|l| (l.id.0 == l2) == l.visible && (l.id.0 != l2) == l.locked)); - s.execute("layer.others", &json!({"id": l2, "show": true, "unlock": true})).unwrap(); - assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.visible && !l.locked)); - let r = s.execute("layer.deleteUnused", &json!({})).unwrap(); - assert_eq!(r["deleted"], 2, "the empty default layer and Empty"); - let _ = (l1, l3); - let l4 = s.execute("layer.new", &json!({"name": "Into"})).unwrap()["id"].as_u64().unwrap(); - s.execute("layer.merge", &json!({"ids": [l4, l2]})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.layers.len(), 1); - assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); - } -} - -#[cfg(test)] -mod page_size_tests { - use serde_json::json; - - #[test] - fn page_size_per_page_keeps_objects_on_their_page() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 3})).unwrap(); - // Page 3 is the right page of spread 2 (pages 2–3); an object on it. - let d = s.doc().unwrap().doc.clone(); - let (si, pi) = d.page_loc(2).unwrap(); - let x0 = d.spreads[si].pages[pi].x; - let id = s.execute("frame.create", &json!({"spread": si, "rect": [x0 + 100.0, 100, x0 + 200.0, 200]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 500})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let left = &d.spreads[si].pages[0]; - assert_eq!((left.width, left.height), (300.0, 500.0)); - assert_eq!(d.spreads[si].pages[1].x, x0, "the spine stays"); - let it = d.item(designcraft_doc::ItemId(id)).unwrap(); - assert_eq!(it.bounds().x0, x0 + 100.0); - assert_eq!(d.page_of_item(it.id), Some(2)); - s.execute("layout.pageSize", &json!({"pages": [3], "preset": "A4"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!((d.spreads[si].pages[1].width - 595.276).abs() < 0.01); - assert!(s.execute("layout.pageSize", &json!({"pages": [9], "width": 100})).is_err()); - } -} - -#[cfg(test)] -mod spread_tests { - use serde_json::json; - - #[test] - fn three_page_spread_survives_page_inserts() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 5})).unwrap(); - // [1] [2 3] [4 5]: page 4 joins spread 1 → [1] [2 3 4] [5]. - let d = s.doc().unwrap().doc.clone(); - let (si, pi) = d.page_loc(3).unwrap(); - let x = d.spreads[si].pages[pi].x; - let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 50.0, 50, x + 150.0, 150]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.pages.toSpread", &json!({"page": 4, "spread": 1})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.spreads[1].pages.len(), 3); - assert!(!d.spreads[1].allow_shuffle); - assert_eq!(d.page_of_item(designcraft_doc::ItemId(id)), Some(3), "the object went with its page"); - // Adding a page at the start reshuffles the others but keeps the 3-page spread. - s.execute("layout.pages.insert", &json!({"after": 0, "count": 1})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.spreads.iter().any(|sp| sp.pages.len() == 3 && !sp.allow_shuffle)); - assert_eq!(d.page_count(), 6); - d.check().unwrap(); - } -} - -#[cfg(test)] -mod page_numbering_view_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn section_and_absolute_page_numbering_view() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - s.execute("layout.section", &json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); - s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); - assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["i", "ii", "1", "2"]); - assert_eq!(s.resolve_page("ii"), Some(1)); - assert_eq!(s.resolve_page("1"), Some(2), "section name first"); - assert_eq!(s.resolve_page("+1"), Some(0), "+n is a position"); - s.execute("prefs.set", &json!({"absolutePageNumbers": true})).unwrap(); - assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["1", "2", "3", "4"]); - assert_eq!(s.resolve_page("1"), Some(0)); - assert_eq!(s.resolve_page("9"), None); - } - - #[test] - fn section_start_numbers_are_bounded() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - for n in [json!(0), json!(100_000), json!(u32::MAX), json!(u64::from(u32::MAX) + 2), json!(-1), json!(2.5), json!("7")] { - let e = s.execute("layout.section", &json!({"page": 1, "startNumber": n})).unwrap_err().to_string(); - assert!(e.contains("startNumber"), "{n}: {e}"); - assert!(s.execute("layout.documentSetup", &json!({"startPage": n})).is_err(), "{n}"); - } - assert_eq!((0..2).map(|i| s.page_label(i)).collect::>(), ["1", "2"]); - let r = s.execute("layout.section", &json!({"page": 1, "startNumber": 99_999})).unwrap(); - assert_eq!(r["names"], json!(["99999", "100000"])); - } - - #[test] - fn numbering_options_apply_in_one_undo_step_or_not_at_all() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - let names = |s: &crate::Session| (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect::>(); - let r = s - .execute( - "layout.numberingOptions", - &json!({"page": 3, "startNumber": 5, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "chapter": {"start": 4, "style": "upperRoman"}}), - ) - .unwrap(); - assert_eq!((r["names"].clone(), r["chapter"].clone()), (json!(["1", "2", "A-v", "A-vi"]), json!("IV"))); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(names(&s), ["1", "2", "3", "4"], "one undo reverts the section…"); - assert_eq!(s.doc().unwrap().doc.chapter_label(), "1", "…and the chapter numbering"); - // A refused part leaves the other undone too. - for bad in [ - json!({"page": 3, "startNumber": 5, "chapter": {"start": 100_000}}), - json!({"page": 3, "startNumber": 4_294_967_295u64, "chapter": {"start": 2}}), - json!({"page": 3, "prefix": "A+B", "chapter": {"start": 2}}), - json!({"page": 9, "chapter": {"start": 2}}), - ] { - assert!(s.execute("layout.numberingOptions", &bad).is_err(), "{bad}"); - assert_eq!(names(&s), ["1", "2", "3", "4"], "{bad}"); - assert_eq!(s.doc().unwrap().doc.settings.chapter_source, designcraft_doc::ChapterSource::Automatic, "{bad}"); - } - // Removing a section keeps the chapter part. - s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); - s.execute("layout.numberingOptions", &json!({"page": 3, "remove": true, "chapter": {"source": "userDefined", "start": 2}})).unwrap(); - assert_eq!(names(&s), ["1", "2", "3", "4"]); - assert_eq!(s.doc().unwrap().doc.chapter_label(), "2"); - } - - #[test] - fn section_prefixes_are_short_and_avoid_plus_and_comma() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - for prefix in ["A-", "Appendix", "§ 1"] { - s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).unwrap(); - } - for prefix in ["Appendix1", "A+", "1,2"] { - assert!(s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).is_err(), "{prefix}"); - } - assert!(s.execute("layout.section", &json!({"page": 2, "style": "symbols"})).is_err()); - assert_eq!(s.doc().unwrap().doc.sections.iter().find(|x| x.start == 1).unwrap().prefix, "§ 1", "a refused prefix changes nothing"); - } - - /// `layout.section` edits one named setting; the others stay as the section was (#717). - #[test] - fn updating_a_section_keeps_settings_the_command_does_not_name() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4})).unwrap(); - s.execute( - "layout.section", - &json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}), - ) - .unwrap(); - assert_eq!(s.page_label(1), "A-i"); - s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there"); - assert_eq!(sec.marker, "Second"); - assert_eq!(sec.start_number, Some(1), "startNumber is kept"); - assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept"); - assert_eq!(sec.prefix, "A-", "prefix is kept"); - assert!(sec.include_prefix, "includePrefix is kept"); - assert_eq!(s.page_label(1), "A-i"); - } -} - -#[cfg(test)] -mod adjust_tests { - use designcraft_doc::Margins; - use serde_json::json; - - use crate::Session; - - #[test] - fn adjust_layout_follows_the_new_page_size_and_margins() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - // Letter, 36 pt margins: a frame filling the left half of the margin box. - let id = s.execute("frame.create", &json!({"rect": [36, 36, 306, 756]})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.documentSetup", &json!({"width": 1224, "adjustLayout": true})).unwrap(); - let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - assert!((b.x0 - 36.0).abs() < 1e-6 && (b.x1 - 612.0).abs() < 1e-6, "half of the wider margin box: {b:?}"); - s.execute("layout.marginsAndColumns", &json!({"margins": 72, "adjustLayout": true})).unwrap(); - let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - assert!((b.x0 - 72.0).abs() < 1e-6 && (b.y0 - 72.0).abs() < 1e-6 && (b.y1 - 720.0).abs() < 1e-6, "{b:?}"); - // Without the option objects stay put. - s.execute("layout.marginsAndColumns", &json!({"margins": 36})).unwrap(); - let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); - assert_eq!(b, b2); - } - - #[test] - fn parent_margins_change_the_parent_and_follow_unoverridden_pages() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - // Page 3 has its own margins (an override); pages 1 and 2 still follow the parent. - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); - let parent_before = s.doc().unwrap().doc.parents[0].pages[0].margins; - assert_eq!(parent_before, Margins::uniform(36.0)); - s.execute("layout.marginsAndColumns", &json!({"parent": 0, "margins": 60, "columns": 2})).unwrap(); - let d = &s.doc().unwrap().doc; - // The parent page itself changed (this is what was drawn on screen). - assert_eq!(d.parents[0].pages[0].margins, Margins::uniform(60.0)); - assert_eq!(d.parents[0].pages[0].columns.count, 2); - // Pages without their own margins follow; the page given its own keeps them. - assert_eq!(d.page(0).unwrap().margins, Margins::uniform(60.0)); - assert_eq!(d.page(1).unwrap().margins, Margins::uniform(60.0)); - assert_eq!(d.page(2).unwrap().margins, Margins::uniform(100.0)); - // An unknown parent is an error, not a silent no-op. - assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err()); - } - - /// Margins and Columns re-divides the columns: a new count or gutter replaces column guides - /// that were dragged (an imported Custom columns page), rather than being ignored by them. - #[test] - fn a_new_column_count_replaces_dragged_column_positions() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - // There is no command for dragging a column guide yet, so set what an import would leave. - let drag = |s: &mut Session| { - s.edit(|d, _| { - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; - pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; - Ok(()) - }) - .unwrap(); - }; - - drag(&mut s); - let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects(); - assert_eq!(dragged.len(), 2); - assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}"); - - // A new count: evenly divided again, and the stale positions are gone. - s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap(); - let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); - assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None)); - assert_eq!(pg.column_rects().len(), 3); - - // So does a new gutter on its own. - drag(&mut s); - s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap(); - let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); - assert_eq!(pg.columns.positions, None); - assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6); - - // Margins that keep the margin box's width leave them alone; a new width scales them. - drag(&mut s); - s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0])); - s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap(); - assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0); - } - - /// The params the Margins and Columns dialog sends on OK: every margin, the count and the - /// gutter, whether or not the user changed them. - fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value { - json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin}, - "columns": columns, "gutter": gutter, "adjustLayout": false}) - } - - const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0]; - - fn dragged() -> designcraft_doc::Columns { - designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) } - } - - /// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide. - #[track_caller] - fn assert_scaled(positions: Option>, width: f64) { - let want: Vec = DRAGGED.iter().map(|p| p * width / 540.0).collect(); - let got = positions.unwrap_or_default(); - assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}"); - } - - /// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs. - #[test] - fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - let drag = |s: &mut Session| { - s.edit(|d, _| { - std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged(); - Ok(()) - }) - .unwrap(); - }; - let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(); - - // The same count and gutter (the gutter as an inch field rounds it) keep them in use. - drag(&mut s); - s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap(); - assert_eq!(positions(&s), Some(DRAGGED.to_vec())); - s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap(); - assert_eq!(positions(&s), Some(DRAGGED.to_vec())); - let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects(); - assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}"); - - // Top and bottom margins leave the margin box's width alone, and the columns with it. - let mut taller = dialog_params(36.0, 2, 12.0); - taller["margins"]["top"] = json!(72.0); - taller["margins"]["bottom"] = json!(72.0); - s.execute("layout.marginsAndColumns", &taller).unwrap(); - assert_eq!(positions(&s), Some(DRAGGED.to_vec())); - - // A new width scales them with the margin box, and the old width scales them back. - s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap(); - assert_scaled(positions(&s), 468.0); - let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects(); - assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}"); - s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap(); - assert_scaled(positions(&s), 540.0); - - // A new count resets them. - drag(&mut s); - s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap(); - let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); - assert_eq!((pg.columns.count, pg.columns.positions), (3, None)); - - // So does a new gutter. - drag(&mut s); - s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap(); - let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); - assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None)); - } - - /// A page of another width (Page Size, Document Setup) scales its dragged columns. - #[test] - fn a_new_page_width_scales_dragged_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - let drag = |s: &mut Session| { - s.edit(|d, _| { - // A dragged page's columns are its own (`local_margins`), as the drag will make them. - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; - pg.columns = dragged(); - pg.local_margins = true; - Ok(()) - }) - .unwrap(); - }; - let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(); - - drag(&mut s); - s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap(); - assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them"); - s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap(); - assert_scaled(positions(&s), 628.0); - s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap(); - assert_scaled(positions(&s), 540.0); - - drag(&mut s); - s.execute("layout.documentSetup", &json!({"width": 700})).unwrap(); - assert_scaled(positions(&s), 628.0); - } - - /// Parent pages follow the same rule, and the document pages that follow them see it. - #[test] - fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - let drag = |s: &mut Session| { - s.edit(|d, _| { - std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged(); - d.sync_parent_margins(); - Ok(()) - }) - .unwrap(); - }; - let positions = |s: &Session| { - let d = &s.doc().unwrap().doc; - (d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone()) - }; - let on_parent = |m: f64, c: u32, g: f64| { - let mut p = dialog_params(m, c, g); - p["parent"] = json!(0); - p - }; - - drag(&mut s); - assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec()))); - s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap(); - assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec()))); - s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap(); - let (parent, follower) = positions(&s); - assert_scaled(parent, 516.0); - assert_scaled(follower, 516.0); - - drag(&mut s); - s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap(); - assert_eq!(positions(&s), (None, None)); - - drag(&mut s); - s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap(); - assert_eq!(positions(&s), (None, None)); - } -} - -#[cfg(test)] -mod section_side_tests { - use serde_json::json; - - use crate::Session; - - /// Each spread's pages as (name, side), side `L`, `R` or `S`. - fn spreads(s: &Session) -> Vec> { - let d = &s.doc().unwrap().doc; - let mut abs = 0; - d.spreads - .iter() - .map(|sp| { - sp.pages - .iter() - .map(|p| { - let side = match p.side { - designcraft_doc::PageSide::Left => 'L', - designcraft_doc::PageSide::Right => 'R', - designcraft_doc::PageSide::Single => 'S', - }; - abs += 1; - (d.page_name(abs - 1), side) - }) - .collect() - }) - .collect() - } - - fn pages(v: &[&[(&str, char)]]) -> Vec> { - v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect() - } - - #[test] - fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() { - // Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the - // section starts on page 8, which becomes a lone right-hand page 1; the pages after it - // shuffle and their objects go with them. Undo puts them back. - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap(); - let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"] - .as_u64() - .unwrap(); - let page_of = |s: &Session| { - let d = &s.doc().unwrap().doc; - d.page_of_item(designcraft_doc::ItemId(id)).unwrap() - }; - assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9"); - let before = spreads(&s); - s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap(); - assert_eq!( - spreads(&s), - pages(&[ - &[("1", 'R')], - &[("2", 'L'), ("3", 'R')], - &[("4", 'L'), ("5", 'R')], - &[("6", 'L'), ("7", 'R')], - &[("1", 'R')], - &[("2", 'L'), ("3", 'R')], - ]) - ); - assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(spreads(&s), before); - // On a right page (the video's page 9) nothing moves; an even start on a right page moves - // it to the left. - s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap(); - assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]); - s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap(); - assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]])); - // Removing the section numbers the pages on from 9 again. - s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap(); - assert_eq!(spreads(&s), before); - } - - #[test] - fn single_sided_documents_keep_their_pages() { - let mut s = Session::new(); - s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap(); - s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap(); - let sp = spreads(&s); - assert_eq!(sp.len(), 4); - assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}"); - } -} - -#[cfg(test)] -mod spread_rotation_input_tests { - use serde_json::json; - - #[test] - fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation; - for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] { - assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}"); - assert_eq!(rotation(&s), 0, "invalid input must not change the view"); - } - s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap(); - assert_eq!(rotation(&s), 1); - s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap(); - assert_eq!(rotation(&s), 0); - s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap(); - assert_eq!(rotation(&s), 2); - s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap(); - assert_eq!(rotation(&s), 0, "zero resets the spread view"); - } -} - -#[cfg(test)] -mod layer_options_tests { - use serde_json::json; - - #[test] - fn layer_set_sets_every_field_and_rejects_bad_names() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let first = s.doc().unwrap().doc.layers[0].id.0; - let id = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); - let all = json!({"id": id, "name": " Artwork ", "color": "Gold", "visible": false, "locked": true, "printable": false, - "showGuides": false, "suppressWrapWhenHidden": true}); - s.execute("layer.set", &all).unwrap(); - let l = s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).cloned().unwrap(); - assert_eq!(l.name, "Artwork"); - assert_eq!(l.color, [255, 153, 0]); - assert!(!l.visible && l.locked && !l.printable && !l.show_guides && l.suppress_wrap_when_hidden); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).unwrap().name, "Art", "one undo step"); - let first_name = s.doc().unwrap().doc.layers.iter().find(|l| l.id.0 == first).unwrap().name.clone(); - let before = s.doc().unwrap().doc.layers.clone(); - for p in [ - json!({"id": id, "name": ""}), - json!({"id": id, "name": " "}), - json!({"id": id, "name": first_name}), - json!({"id": id, "name": "Fine", "color": "Plaid"}), - json!({"id": id, "color": [300, 0, 0]}), - json!({"id": id, "visible": "yes"}), - json!({"id": 999_999, "name": "Ghost"}), - ] { - assert!(s.execute("layer.set", &p).is_err(), "{p}"); - } - assert_eq!(s.doc().unwrap().doc.layers, before); - // Keeping its own name is fine. - s.execute("layer.set", &json!({"id": id, "name": "Art", "color": [1, 2, 3]})).unwrap(); - } -} - -#[cfg(test)] -mod parent_margin_tests { - use designcraft_doc::{Document, Margins}; - use serde_json::json; - - use crate::Session; - - fn margins(d: &Document) -> Vec<(Margins, u32, bool)> { - (0..d.page_count()).filter_map(|i| d.page(i)).map(|p| (p.margins, p.columns.count, p.local_margins)).collect() - } - - #[test] - fn document_setup_margins_let_pages_follow_their_parent_page_again() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"pages": [1], "margins": 100})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100, "columns": 3})).unwrap(); - s.execute("layout.documentSetup", &json!({"margins": 20})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - let twenty = Margins::uniform(20.0); - assert_eq!(m, vec![(twenty, 1, false), (twenty, 1, false), (twenty, 3, true)], "own columns stay local"); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 50})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[1], (Margins::uniform(50.0), 1, false), "page 2 follows the parent page again"); - assert_eq!(m[2], (twenty, 3, true)); - } - - #[test] - fn pages_follow_their_parent_pages_margins_unless_set_locally() { - let mut s = Session::new(); - // Facing: [1] [2 3]; page 3 gets its own margins. - s.execute("file.new", &json!({"pages": 3})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); - // A's left page: only the left document page follows. - let left = json!({"top": 10, "bottom": 20, "inside": 30, "outside": 40}); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "pages": [0], "margins": left, "columns": 2})).unwrap(); - let l = Margins { top: 10.0, bottom: 20.0, inside: 30.0, outside: 40.0 }; - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m, vec![(Margins::uniform(36.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); - // A's right page: page 1 follows, page 3 keeps its own. - s.execute("layout.marginsAndColumns", &json!({"parent": "A-Parent", "pages": [1], "margins": 50})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m, vec![(Margins::uniform(50.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); - // A page inserted after page 1 pushes page 2 to the right: it takes the right parent page's. - s.execute("layout.pages.insert", &json!({"after": 0, "count": 1, "parent": "A"})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[1], (l, 2, false), "the new left page"); - assert_eq!(m[2], (Margins::uniform(50.0), 1, false), "old page 2, now a right page"); - assert_eq!(m[3], (Margins::uniform(100.0), 1, true)); - assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); - } - - #[test] - fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { - let mut s = Session::new(); - // Facing: [1] [2 3]. - s.execute("file.new", &json!({"pages": 3})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"parent": "B", "margins": 70, "columns": 3})).unwrap(); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "the page takes B's margins"); - // Page 3 is given margins equal to A's: they are its own all the same. - s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 36})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 20})).unwrap(); - let m = margins(&s.doc().unwrap().doc); - assert_eq!(m[0], (Margins::uniform(20.0), 1, false)); - assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "B's page is not A's"); - assert_eq!(m[2], (Margins::uniform(36.0), 1, true)); - // Back to A: page 2 follows A again. - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "A"})).unwrap(); - assert_eq!(margins(&s.doc().unwrap().doc)[1], (Margins::uniform(20.0), 1, false)); - } - - #[test] - fn parent_guides_show_on_pages_but_stay_on_the_parent() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - // A vertical guide 100 pt into A's right page (x = 612 in the parent spread). - s.execute("guide.add", &json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712})).unwrap(); - let d = &s.doc().unwrap().doc; - let right = d.page(0).unwrap(); - let shown: Vec = d.parent_guides(right).map(|g| g.position).collect(); - assert_eq!(shown, vec![100.0], "moved to the document page's place"); - assert_eq!(d.parent_guides(d.page(1).unwrap()).count(), 0, "the left page shows A's left page's guides"); - // Not the document page's own: not listed, moved or deleted from there. - assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap(), json!([])); - assert!(s.execute("guide.move", &json!({"spread": 0, "page": 0, "index": 0, "position": 50})).is_err()); - assert!(s.execute("guide.delete", &json!({"spread": 0, "page": 0, "index": 0})).is_err()); - } -} - -#[cfg(test)] -mod parent_tests { - use serde_json::json; - - fn parent_of(s: &crate::Session, page: usize) -> Option { - s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0) - } - - #[test] - fn parents_with_one_prefix_stay_distinct() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 2})).unwrap(); - let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap(); - let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap(); - s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); - assert_eq!(parent_of(&s, 0), Some(b)); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap(); - assert_eq!(parent_of(&s, 1), Some(b)); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap(); - assert_eq!(parent_of(&s, 1), Some(a)); - // A prefix two parents share, or a name no parent has, is an error and changes nothing. - assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err()); - assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err()); - assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err()); - assert_eq!(parent_of(&s, 1), Some(a)); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap(); - assert_eq!(parent_of(&s, 1), None); - } - - #[test] - fn edit_parents_shows_the_named_parent() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let ids: Vec = - ["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect(); - s.ui_requests.clear(); - let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap(); - assert_eq!(r, json!({"on": true, "parent": ids[2]})); - assert!(s.doc().unwrap().editing_parents); - let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap(); - let shown: Vec<_> = s - .ui_requests - .iter() - .filter_map(|r| match r { - crate::UiRequest::View { params } => params.get("showSpread").cloned(), - _ => None, - }) - .collect(); - assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]); - let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap(); - assert_eq!(r["parent"], ids[1]); - assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err()); - let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap(); - assert_eq!(r, json!({"on": false, "parent": null})); - assert!(!s.doc().unwrap().editing_parents); - } - - /// A new parent takes the first page's columns, dragged column guides included: the right - /// parent page as the first (right) page has them, the left one mirrored across the spine. - /// `document.inspect` reports where those columns are. - #[test] - fn a_new_parent_keeps_dragged_columns() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; - // There is no command for dragging a column guide yet, so set what an import would leave. - s.edit(|d, _| { - let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; - (pg.columns, pg.local_margins) = (dragged.clone(), true); - Ok(()) - }) - .unwrap(); - let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); - let d = &s.doc().unwrap().doc; - let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap(); - assert_eq!(parent.pages.len(), 2); - let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap(); - let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right)); - assert_eq!(right.columns, dragged); - assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::>(), vec![100.0, 410.0]); - assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0])); - assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::>(), vec![410.0, 100.0]); - - let inspect = s.execute("document.inspect", &json!({})).unwrap(); - let page = &inspect["spreads"][0]["pages"][0]; - assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}"); - assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}"); - } - - fn columns_of(s: &crate::Session, abs: usize) -> (Option>, bool) { - let pg = s.doc().unwrap().doc.page(abs).unwrap().clone(); - (pg.columns.positions, pg.local_margins) - } - - /// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked. - fn three_columns(pages: u64) -> crate::Session { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": pages})).unwrap(); - s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap(); - s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); - s - } - - /// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring - /// gutter and the margin, makes a following page's columns its own, and undoes in one step. - #[test] - fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() { - let mut s = three_columns(1); - assert_eq!(columns_of(&s, 0), (None, false), "the page follows A"); - let mv = - |s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x})); - let r = mv(&mut s, 0, 236.0).unwrap(); - assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0})); - assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); - let inspect = s.execute("document.inspect", &json!({})).unwrap(); - assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0])); - // Past the next gutter: it stops there, and the middle column has no width. - assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0)); - // Past the right margin: the last gutter stops 12 pt (its width) inside it. - assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0])); - // Past the left margin. - assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0])); - for _ in 0..3 { - s.execute("edit.undo", &json!({})).unwrap(); - } - assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag"); - } - - /// On a parent page the drag changes the parent, and the document pages that follow it get - /// the new columns; a page with columns of its own keeps them. - #[test] - fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() { - let mut s = three_columns(3); - // Page 1, the left page of spread 1, gets columns of its own. - let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0; - s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap(); - let own = columns_of(&s, 1); - assert!(own.1 && own.0.is_some()); - let d = &s.doc().unwrap().doc; - // A's right page: the one page 0 (a right page) and page 2 follow. - let x0 = d.parents[0].pages[1].margin_rect().x0; - s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap(); - let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0]; - assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone())); - assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false)); - assert_eq!(columns_of(&s, 2), (Some(moved), false)); - assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own"); - // The same parent page by spread reference. - let spread = json!({"kind": "parent", "index": 0}); - s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap(); - assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0); - } - - /// A drag's preview in one document is put back there when another document became active. - #[test] - fn a_column_guide_preview_is_cancelled_in_its_own_document() { - let mut s = three_columns(1); - let uid = s.doc().unwrap().uid; - s.begin_interaction("Move Column Guide").unwrap(); - s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap(); - s.execute("file.new", &json!({})).unwrap(); - s.cancel_interaction_of(uid); - s.set_active(0); - assert!(s.doc().unwrap().interaction.is_none()); - assert_eq!(columns_of(&s, 0), (None, false)); - } - - #[test] - fn move_column_guide_refuses_while_locked_and_hostile_params() { - let mut s = three_columns(1); - s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap(); - assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err()); - assert_eq!(columns_of(&s, 0), (None, false)); - s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); - for p in [ - json!({"page": 0, "gutter": 0}), - json!({"page": 0, "gutter": 0, "position": f64::NAN}), - json!({"page": 0, "gutter": 0, "position": "200"}), - json!({"page": 0, "gutter": 2, "position": 200}), - json!({"page": 0, "gutter": u64::MAX, "position": 200}), - json!({"page": 0, "gutter": -1, "position": 200}), - json!({"page": 0, "gutter": 0.5, "position": 200}), - json!({"page": 9, "gutter": 0, "position": 200}), - json!({"page": u64::MAX, "gutter": 0, "position": 200}), - json!({"gutter": 0, "position": 200}), - json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}), - json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}), - json!({"page": 0, "gutter": 0, "position": f64::MAX}), - ] { - let r = s.execute("layout.moveColumnGuide", &p); - // A huge but finite position is clamped to the margin; everything else is refused. - assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}"); - } - } -} - -#[cfg(test)] -mod parent_base_tests { - use serde_json::{Value, json}; - - fn rect(s: &mut crate::Session, spread: Value, r: [f64; 4], swatch: &str) -> u64 { - let id = s.execute("frame.create", &json!({"spread": spread, "rect": r, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.fill", &json!({"ids": [id], "swatch": swatch})).unwrap(); - id - } - - fn page(s: &crate::Session, abs: usize) -> designcraft_render::Rendered { - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&s.doc().unwrap().doc, &s.cache, abs, 1.0, false, &Default::default()).unwrap() - } - - /// Parent A (black), parent B based on A (cyan, half over A's square), and a page item (paper) - /// over B's: the page draws A, then B, then its own items. - #[test] - fn based_on_parent_draws_base_items_under_parent_items() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 400, "height": 400, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Opening"})).unwrap(); - rect(&mut s, json!({"kind": "parent", "index": 0}), [0.0, 0.0, 200.0, 200.0], "[Black]"); - rect(&mut s, json!({"kind": "parent", "index": 1}), [100.0, 0.0, 300.0, 200.0], "C=100 M=0 Y=0 K=0"); - rect(&mut s, json!(1), [150.0, 0.0, 190.0, 40.0], "[Paper]"); - s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); - let black = |p: [u8; 4]| p[0] < 80 && p[1] < 80 && p[2] < 80; - let cyan = |p: [u8; 4]| p[0] < 80 && p[1] > 120 && p[2] > 180; - let paper = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] > 240; - assert!(paper(page(&s, 1).pixel(50, 100)), "B alone does not show A's items"); - - let r = s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap(); - assert_eq!(r, json!({"parent": "B-Opening", "basedOn": "A-Parent"})); - let img = page(&s, 1); - assert!(black(img.pixel(50, 100)), "A's item: {:?}", img.pixel(50, 100)); - assert!(cyan(img.pixel(150, 150)), "B's item over A's: {:?}", img.pixel(150, 150)); - assert!(paper(img.pixel(170, 20)), "the page's item over B's: {:?}", img.pixel(170, 20)); - assert!(black(page(&s, 0).pixel(50, 100)) && !cyan(page(&s, 0).pixel(250, 100)), "page 1 uses A only"); - // Parent B itself shows A's items under its own. - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let placed = [designcraft_render::Placed { spread: designcraft_doc::SpreadRef::Parent(1), xf: designcraft_geom::Affine::IDENTITY }]; - let opts = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; - let img = rr.render(&s.doc().unwrap().doc, &s.cache, &placed, 400, 400, designcraft_geom::Affine::IDENTITY, &opts); - assert!(black(img.pixel(50, 100)) && cyan(img.pixel(150, 150)), "{:?} {:?}", img.pixel(50, 100), img.pixel(150, 150)); - let inspect = s.execute("document.inspect", &json!({})).unwrap(); - assert_eq!(inspect["parents"][1]["basedOn"], "A-Parent"); - assert_eq!(inspect["parents"][0]["basedOn"], Value::Null); - - // Cycles are refused. - assert!(s.execute("layout.parents.options", &json!({"parent": "A", "basedOn": "B"})).is_err()); - assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "B-Opening"})).is_err()); - assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "Z"})).is_err()); - - s.execute("edit.undo", &json!({})).unwrap(); - assert!(paper(page(&s, 1).pixel(50, 100)), "undo clears Based on Parent"); - s.execute("edit.redo", &json!({})).unwrap(); - s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": null})).unwrap(); - assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["parents"][1]["basedOn"], Value::Null); - } - - /// A's page-number marker shown on parent XYZ (based on A) reads XYZ, as markers on XYZ's own items do. - #[test] - fn base_parent_markers_read_the_showing_parent() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "XYZ"})).unwrap(); - s.execute("layout.parents.options", &json!({"parent": "XYZ", "basedOn": "A"})).unwrap(); - let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let sid = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let glyphs = |on| -> usize { - let cs = s.cache.get_on(&d, sid, on); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(glyphs(designcraft_compose::ShownOn::Own), 1, "A"); - assert_eq!(glyphs(designcraft_compose::ShownOn::Parent(1)), 3, "XYZ"); - } - - /// A file whose parents are based on each other opens, with the cycle broken and reported. - #[test] - fn opening_a_parent_cycle_breaks_it_with_a_warning() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); - let mut d = s.doc().unwrap().doc.as_ref().clone(); - let (a, b) = (d.parents[0].id, d.parents[1].id); - std::sync::Arc::make_mut(&mut d.parents[0]).parent.as_mut().unwrap().based_on = Some(b); - std::sync::Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a); - let b64 = crate::cmd::base64_encode(&crate::cmd::to_bytes(&d).unwrap()); - let r = s.execute("file.openBytes", &json!({"name": "cycle", "base64": b64})).unwrap(); - assert!(r["warnings"].to_string().contains("cycle"), "{r}"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); - assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a)); - } -} +mod layers; +mod margins; +mod pages; +mod parents; +mod registry; +mod sections; + +pub(crate) use pages::resize_pages; +pub use registry::specs; +pub use sections::chapter_numbering; #[cfg(test)] #[path = "parent_delete_tests.rs"] mod parent_delete_tests; - -#[cfg(test)] -mod insert_count_tests { - use serde_json::json; - - use crate::Session; - - /// A count that is given but is not a number of pages used to insert one page anyway: - /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped - /// to 9999. A position past the last page appends, which is deliberate. - #[test] - fn a_page_count_insert_pages_cannot_mean_is_refused() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); - let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap(); - assert_eq!(total(&mut s), 3, "one page added"); - - for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] { - let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string(); - assert!(e.contains("1 to 9999 pages"), "{params}: {e}"); - } - // Nothing was inserted by any of those. - assert_eq!(s.doc().unwrap().doc.page_count(), 3); - // A position past the last page still appends, which `insert_pages_bounds.rs` pins. - let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap(); - assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4))); - // The positions it does have still work: after the first page of three. - let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap(); - assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6))); - } -} diff --git a/crates/engine/src/cmd/layout/layers.rs b/crates/engine/src/cmd/layout/layers.rs new file mode 100644 index 000000000..80fbc9d28 --- /dev/null +++ b/crates/engine/src/cmd/layout/layers.rs @@ -0,0 +1,137 @@ +use super::{LAYER_COLORS, LayerId, Result, Session, Value, bad, ok}; + +/// Layer Options: every field in one undo step; a bad name or colour changes nothing. +pub(super) fn set_layer(s: &mut Session, p: &Value) -> Result { + const ID: &str = "layer.set"; + let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(ID, "missing id"))?); + let name = match p.get("name") { + Some(v) => Some(v.as_str().map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "a layer needs a name"))?.to_string()), + None => None, + }; + let color: Option<[u8; 3]> = match p.get("color") { + None => None, + Some(Value::String(n)) => Some( + LAYER_COLORS + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(n)) + .map(|(_, c)| *c) + .ok_or_else(|| bad(ID, format!("unknown layer colour `{n}`")))?, + ), + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|_| bad(ID, "color is [r,g,b] (0–255) or a layer colour name"))?), + }; + let flag = |k: &str| match p.get(k) { + None => Ok(None), + Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(ID, format!("`{k}` is true or false"))), + }; + let (visible, locked, printable) = (flag("visible")?, flag("locked")?, flag("printable")?); + let (guides, wrap) = (flag("showGuides")?, flag("suppressWrapWhenHidden")?); + s.edit(|d, _| { + if let Some(n) = &name + && d.layers.iter().any(|l| l.id != id && l.name == *n) + { + return Err(bad(ID, format!("a layer named `{n}` exists"))); + } + let l = d.layer_mut(id).ok_or_else(|| bad(ID, "no such layer"))?; + if let Some(n) = name { + l.name = n; + } + if let Some(c) = color { + l.color = c; + } + for (slot, v) in [ + (&mut l.visible, visible), + (&mut l.locked, locked), + (&mut l.printable, printable), + (&mut l.show_guides, guides), + (&mut l.suppress_wrap_when_hidden, wrap), + ] { + if let Some(v) = v { + *slot = v; + } + } + ok() + }) +} + +#[cfg(test)] +mod layer_tests { + use serde_json::json; + + #[test] + fn layer_move_reorders_and_rejects_bad_input() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap(); + let order = |s: &crate::Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::>(); + assert_eq!(order(&s), [l2, l1]); + for bad in [json!({"id": l1, "to": 2}), json!({"id": 999, "to": 0}), json!({"id": l1}), json!({"to": 0})] { + assert!(s.execute("layer.move", &bad).is_err(), "{bad}"); + assert_eq!(order(&s), [l2, l1]); + } + s.execute("layer.move", &json!({"id": l1, "to": 0})).unwrap(); + assert_eq!(order(&s), [l1, l2]); + } + + #[test] + fn merge_delete_unused_hide_and_lock_others() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let l1 = s.doc().unwrap().doc.layers[0].id.0; + let l2 = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + let l3 = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.activate", &json!({"id": l2})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.others", &json!({"id": l2, "hide": true, "lock": true})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.layers.iter().all(|l| (l.id.0 == l2) == l.visible && (l.id.0 != l2) == l.locked)); + s.execute("layer.others", &json!({"id": l2, "show": true, "unlock": true})).unwrap(); + assert!(s.doc().unwrap().doc.layers.iter().all(|l| l.visible && !l.locked)); + let r = s.execute("layer.deleteUnused", &json!({})).unwrap(); + assert_eq!(r["deleted"], 2, "the empty default layer and Empty"); + let _ = (l1, l3); + let l4 = s.execute("layer.new", &json!({"name": "Into"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.merge", &json!({"ids": [l4, l2]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.layers.len(), 1); + assert_eq!(d.item(designcraft_doc::ItemId(f)).unwrap().layer.0, l4); + } +} + +#[cfg(test)] +mod layer_options_tests { + use serde_json::json; + + #[test] + fn layer_set_sets_every_field_and_rejects_bad_names() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let first = s.doc().unwrap().doc.layers[0].id.0; + let id = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap(); + let all = json!({"id": id, "name": " Artwork ", "color": "Gold", "visible": false, "locked": true, "printable": false, + "showGuides": false, "suppressWrapWhenHidden": true}); + s.execute("layer.set", &all).unwrap(); + let l = s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).cloned().unwrap(); + assert_eq!(l.name, "Artwork"); + assert_eq!(l.color, [255, 153, 0]); + assert!(!l.visible && l.locked && !l.printable && !l.show_guides && l.suppress_wrap_when_hidden); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layer(designcraft_doc::LayerId(id)).unwrap().name, "Art", "one undo step"); + let first_name = s.doc().unwrap().doc.layers.iter().find(|l| l.id.0 == first).unwrap().name.clone(); + let before = s.doc().unwrap().doc.layers.clone(); + for p in [ + json!({"id": id, "name": ""}), + json!({"id": id, "name": " "}), + json!({"id": id, "name": first_name}), + json!({"id": id, "name": "Fine", "color": "Plaid"}), + json!({"id": id, "color": [300, 0, 0]}), + json!({"id": id, "visible": "yes"}), + json!({"id": 999_999, "name": "Ghost"}), + ] { + assert!(s.execute("layer.set", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().doc.layers, before); + // Keeping its own name is fine. + s.execute("layer.set", &json!({"id": id, "name": "Art", "color": [1, 2, 3]})).unwrap(); + } +} diff --git a/crates/engine/src/cmd/layout/margins.rs b/crates/engine/src/cmd/layout/margins.rs new file mode 100644 index 000000000..bcfa6c431 --- /dev/null +++ b/crates/engine/src/cmd/layout/margins.rs @@ -0,0 +1,423 @@ +use super::parents::parent_spread_index; +use super::{Margins, Result, Session, Value, bad, has_doc, json}; + +/// Each page's margin box, by spread and page id. +pub(super) fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { + d.spread_refs() + .filter_map(|r| d.spread(r).map(|sp| (r, sp))) + .flat_map(|(r, sp)| sp.pages.iter().map(move |pg| (r, pg.id, pg.margin_rect()))) + .collect() +} + +/// Layout Adjustment: objects on each page move and resize from its old margin box to its new +/// one (content keeps its size; frames that auto-fit refit). +pub(super) fn adjust_layout( + d: &mut designcraft_doc::Document, + before: &[(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)], +) { + use designcraft_geom::Affine; + let after = margin_boxes(d); + for (r, pid, old) in before { + let Some((_, _, new)) = after.iter().find(|(r2, p2, _)| r2 == r && p2 == pid) else { continue }; + if old == new || old.width() < 1e-6 || old.height() < 1e-6 { + continue; + } + let m = Affine::translate((new.x0, new.y0)) + * Affine::scale_non_uniform(new.width() / old.width(), new.height() / old.height()) + * Affine::translate((-old.x0, -old.y0)); + let Some(sp) = d.spread(*r) else { continue }; + // The page's objects (by centre, measured against the old boxes). + let ids: Vec = sp + .items + .iter() + .filter(|it| { + let c = it.bounds().center(); + // Pages share a spread side by side: the margin box's page is the one under the centre's x. + before + .iter() + .filter(|(r2, _, _)| r2 == r) + .min_by(|a, b| dist(a.2, c.x).total_cmp(&dist(b.2, c.x))) + .is_some_and(|(_, p2, _)| p2 == pid) + }) + .map(|it| it.id) + .collect(); + for id in ids { + let Some(it) = d.item_mut(id) else { continue }; + if matches!(it.content, designcraft_doc::Content::Group { .. }) { + it.xf = m * it.xf; + } else { + let inner = it.xf.inverse() * m * it.xf; + it.path.transform(inner); + if let Some(w) = &mut it.wrap.path { + w.transform(inner); + } + let ib = it.inner_bounds(); + if let designcraft_doc::Content::Graphic(g) = &mut it.content { + // Content keeps its size but moves with the frame; auto-fit refits. + let o = inner * designcraft_geom::Point::ZERO; + g.xf = Affine::translate(o.to_vec2()) * g.xf; + if let Some(xf) = g.fitted(ib, g.auto_fit) { + g.xf = xf; + } + } + } + } + } +} + +/// Horizontal distance from `x` to a margin box (0 inside). +fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { + if x < r.x0 { + r.x0 - x + } else if x > r.x1 { + x - r.x1 + } else { + 0.0 + } +} + +/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to one page. +pub(super) fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) { + let width_before = pg.margin_rect().width(); + match p.get("margins") { + Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)), + Some(v @ Value::Object(_)) => { + if let Ok(m) = serde_json::from_value(v.clone()) { + pg.margins = m; + } + } + _ => {} + } + scale_columns(pg, width_before); + // A new count or gutter re-divides the columns evenly: dragged column positions are what + // the new value replaces. The dialog always sends both, so an unchanged value keeps them. + if let Some(c) = p.get("columns").and_then(Value::as_u64) { + let c = u32::try_from(c).unwrap_or(u32::MAX).clamp(1, 216); + if c != pg.columns.count { + pg.columns.count = c; + pg.columns.positions = None; + } + } + if let Some(g) = p.get("gutter").and_then(Value::as_f64).filter(|g| g.is_finite()) { + let g = g.max(0.0); + if (g - pg.columns.gutter).abs() > GUTTER_TOLERANCE { + pg.columns.gutter = g; + pg.columns.positions = None; + } + } +} + +/// Dragged column positions are measured across the margin box. When the box was +/// `width_before` wide and now has another width, they scale with it, as InDesign's do; a set +/// that doesn't scale into a valid one goes back to the even split rather than stay hidden. +fn scale_columns(pg: &mut designcraft_doc::Page, width_before: f64) { + let width = pg.margin_rect().width(); + if (width - width_before).abs() <= GUTTER_TOLERANCE { + return; + } + let Some(positions) = pg.columns.positions.as_mut() else { return }; + let factor = width / width_before; + if factor.is_finite() && factor > 0.0 { + positions.iter_mut().for_each(|p| *p *= factor); + // The ends sit exactly on the margins whatever the rounding. + if let Some(first) = positions.first_mut() { + *first = 0.0; + } + if let Some(last) = positions.last_mut() { + *last = width; + } + } + if pg.columns.valid_positions(width).is_none() { + pg.columns.positions = None; + } +} + +/// Gives `pg` the page width `width`, scaling its dragged columns with the margin box. +pub(super) fn set_page_width(pg: &mut designcraft_doc::Page, width: f64) { + let before = pg.margin_rect().width(); + pg.width = width; + scale_columns(pg, before); +} + +/// How far a gutter may differ from the page's and still count as unchanged, in points. The +/// dialog shows the gutter in the ruler unit, rounded (inches to four decimals: up to 0.0036 pt +/// off), so an OK that leaves the field alone sends back a slightly different number. +const GUTTER_TOLERANCE: f64 = 0.01; + +/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column +/// Guides is off. +pub(super) fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + match s.active() { + Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()), + _ => Ok(()), + } +} + +/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`. +pub(super) fn move_column_guide(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layout.moveColumnGuide"; + let index = |key: &str| -> Result { + let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?; + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer"))) + }; + let (page, gutter) = (index("page")?, index("gutter")?); + let position = + p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?; + let r = match p.get("parent") { + None | Some(Value::Null) => crate::cmd::spread_param(p, CMD)?, + Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?), + }; + s.edit(|d, _| { + if d.settings.column_guides_locked { + return Err(bad(CMD, "column guides are locked")); + } + let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?; + let m = pg.margin_rect(); + let count = pg.columns.count; + if gutter.checked_add(1).is_none_or(|g| g >= count as usize) { + return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1)))); + } + let positions = + pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?; + let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v); + pg.columns.positions = Some(positions.clone()); + match r { + designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true, + designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(), + } + Ok(json!({"positions": positions, "left": left})) + }) +} + +#[cfg(test)] +mod adjust_tests { + use designcraft_doc::Margins; + use serde_json::json; + + use crate::Session; + + #[test] + fn adjust_layout_follows_the_new_page_size_and_margins() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + // Letter, 36 pt margins: a frame filling the left half of the margin box. + let id = s.execute("frame.create", &json!({"rect": [36, 36, 306, 756]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.documentSetup", &json!({"width": 1224, "adjustLayout": true})).unwrap(); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert!((b.x0 - 36.0).abs() < 1e-6 && (b.x1 - 612.0).abs() < 1e-6, "half of the wider margin box: {b:?}"); + s.execute("layout.marginsAndColumns", &json!({"margins": 72, "adjustLayout": true})).unwrap(); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert!((b.x0 - 72.0).abs() < 1e-6 && (b.y0 - 72.0).abs() < 1e-6 && (b.y1 - 720.0).abs() < 1e-6, "{b:?}"); + // Without the option objects stay put. + s.execute("layout.marginsAndColumns", &json!({"margins": 36})).unwrap(); + let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert_eq!(b, b2); + } + + #[test] + fn parent_margins_change_the_parent_and_follow_unoverridden_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + // Page 3 has its own margins (an override); pages 1 and 2 still follow the parent. + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); + let parent_before = s.doc().unwrap().doc.parents[0].pages[0].margins; + assert_eq!(parent_before, Margins::uniform(36.0)); + s.execute("layout.marginsAndColumns", &json!({"parent": 0, "margins": 60, "columns": 2})).unwrap(); + let d = &s.doc().unwrap().doc; + // The parent page itself changed (this is what was drawn on screen). + assert_eq!(d.parents[0].pages[0].margins, Margins::uniform(60.0)); + assert_eq!(d.parents[0].pages[0].columns.count, 2); + // Pages without their own margins follow; the page given its own keeps them. + assert_eq!(d.page(0).unwrap().margins, Margins::uniform(60.0)); + assert_eq!(d.page(1).unwrap().margins, Margins::uniform(60.0)); + assert_eq!(d.page(2).unwrap().margins, Margins::uniform(100.0)); + // An unknown parent is an error, not a silent no-op. + assert!(s.execute("layout.marginsAndColumns", &json!({"parent": 9, "margins": 10})).is_err()); + } + + /// Margins and Columns re-divides the columns: a new count or gutter replaces column guides + /// that were dragged (an imported Custom columns page), rather than being ignored by them. + #[test] + fn a_new_column_count_replaces_dragged_column_positions() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + // There is no command for dragging a column guide yet, so set what an import would leave. + let drag = |s: &mut Session| { + s.edit(|d, _| { + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + pg.columns = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + Ok(()) + }) + .unwrap(); + }; + + drag(&mut s); + let dragged = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert_eq!(dragged.len(), 2); + assert!((dragged[0].x1 - dragged[0].x0 - 100.0).abs() < 1e-6, "the dragged gutter is in use: {dragged:?}"); + + // A new count: evenly divided again, and the stale positions are gone. + s.execute("layout.marginsAndColumns", &json!({"columns": 3})).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.count, pg.columns.positions.clone()), (3, None)); + assert_eq!(pg.column_rects().len(), 3); + + // So does a new gutter on its own. + drag(&mut s); + s.execute("layout.marginsAndColumns", &json!({"gutter": 24})).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!(pg.columns.positions, None); + assert!((pg.column_rects()[1].x0 - pg.column_rects()[0].x1 - 24.0).abs() < 1e-6); + + // Margins that keep the margin box's width leave them alone; a new width scales them. + drag(&mut s); + s.execute("layout.marginsAndColumns", &json!({"margins": {"top": 72, "bottom": 72, "inside": 36, "outside": 36}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page(0).unwrap().columns.positions, Some(vec![0.0, 100.0, 130.0, 540.0])); + s.execute("layout.marginsAndColumns", &json!({"margins": 72})).unwrap(); + assert_scaled(s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(), 468.0); + } + + /// The params the Margins and Columns dialog sends on OK: every margin, the count and the + /// gutter, whether or not the user changed them. + fn dialog_params(margin: f64, columns: u32, gutter: f64) -> serde_json::Value { + json!({"margins": {"top": margin, "bottom": margin, "inside": margin, "outside": margin}, + "columns": columns, "gutter": gutter, "adjustLayout": false}) + } + + const DRAGGED: [f64; 4] = [0.0, 100.0, 130.0, 540.0]; + + fn dragged() -> designcraft_doc::Columns { + designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(DRAGGED.to_vec()) } + } + + /// `positions` are [`DRAGGED`] scaled from its 540 pt margin box to one `width` wide. + #[track_caller] + fn assert_scaled(positions: Option>, width: f64) { + let want: Vec = DRAGGED.iter().map(|p| p * width / 540.0).collect(); + let got = positions.unwrap_or_default(); + assert!(got.len() == want.len() && got.iter().zip(&want).all(|(g, w)| (g - w).abs() < 1e-6), "{got:?} ≠ {want:?}"); + } + + /// An OK in the dialog keeps dragged columns unless the count or gutter it sends differs. + #[test] + fn the_dialog_keeps_dragged_columns_unless_the_count_or_gutter_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let drag = |s: &mut Session| { + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0].columns = dragged(); + Ok(()) + }) + .unwrap(); + }; + let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(); + + // The same count and gutter (the gutter as an inch field rounds it) keep them in use. + drag(&mut s); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec())); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 0.1667 * 72.0)).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec())); + let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert!((rects[0].x1 - rects[0].x0 - 100.0).abs() < 1e-6, "the dragged columns are in use: {rects:?}"); + + // Top and bottom margins leave the margin box's width alone, and the columns with it. + let mut taller = dialog_params(36.0, 2, 12.0); + taller["margins"]["top"] = json!(72.0); + taller["margins"]["bottom"] = json!(72.0); + s.execute("layout.marginsAndColumns", &taller).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec())); + + // A new width scales them with the margin box, and the old width scales them back. + s.execute("layout.marginsAndColumns", &dialog_params(72.0, 2, 12.0)).unwrap(); + assert_scaled(positions(&s), 468.0); + let rects = s.doc().unwrap().doc.page(0).unwrap().column_rects(); + assert!((rects[0].x1 - rects[0].x0 - 100.0 * 468.0 / 540.0).abs() < 1e-6, "the scaled columns are in use: {rects:?}"); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 12.0)).unwrap(); + assert_scaled(positions(&s), 540.0); + + // A new count resets them. + drag(&mut s); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 3, 12.0)).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.count, pg.columns.positions), (3, None)); + + // So does a new gutter. + drag(&mut s); + s.execute("layout.marginsAndColumns", &dialog_params(36.0, 2, 18.0)).unwrap(); + let pg = s.doc().unwrap().doc.page(0).unwrap().clone(); + assert_eq!((pg.columns.gutter, pg.columns.positions), (18.0, None)); + } + + /// A page of another width (Page Size, Document Setup) scales its dragged columns. + #[test] + fn a_new_page_width_scales_dragged_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let drag = |s: &mut Session| { + s.edit(|d, _| { + // A dragged page's columns are its own (`local_margins`), as the drag will make them. + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + pg.columns = dragged(); + pg.local_margins = true; + Ok(()) + }) + .unwrap(); + }; + let positions = |s: &Session| s.doc().unwrap().doc.page(0).unwrap().columns.positions.clone(); + + drag(&mut s); + s.execute("layout.pageSize", &json!({"pages": [1], "height": 900})).unwrap(); + assert_eq!(positions(&s), Some(DRAGGED.to_vec()), "a new height keeps them"); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700})).unwrap(); + assert_scaled(positions(&s), 628.0); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 612})).unwrap(); + assert_scaled(positions(&s), 540.0); + + drag(&mut s); + s.execute("layout.documentSetup", &json!({"width": 700})).unwrap(); + assert_scaled(positions(&s), 628.0); + } + + /// Parent pages follow the same rule, and the document pages that follow them see it. + #[test] + fn the_dialog_keeps_a_parents_dragged_columns_unless_the_count_or_gutter_changes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let drag = |s: &mut Session| { + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].columns = dragged(); + d.sync_parent_margins(); + Ok(()) + }) + .unwrap(); + }; + let positions = |s: &Session| { + let d = &s.doc().unwrap().doc; + (d.parents[0].pages[0].columns.positions.clone(), d.page(0).unwrap().columns.positions.clone()) + }; + let on_parent = |m: f64, c: u32, g: f64| { + let mut p = dialog_params(m, c, g); + p["parent"] = json!(0); + p + }; + + drag(&mut s); + assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec()))); + s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 12.0)).unwrap(); + assert_eq!(positions(&s), (Some(DRAGGED.to_vec()), Some(DRAGGED.to_vec()))); + s.execute("layout.marginsAndColumns", &on_parent(48.0, 2, 12.0)).unwrap(); + let (parent, follower) = positions(&s); + assert_scaled(parent, 516.0); + assert_scaled(follower, 516.0); + + drag(&mut s); + s.execute("layout.marginsAndColumns", &on_parent(36.0, 3, 12.0)).unwrap(); + assert_eq!(positions(&s), (None, None)); + + drag(&mut s); + s.execute("layout.marginsAndColumns", &on_parent(36.0, 2, 24.0)).unwrap(); + assert_eq!(positions(&s), (None, None)); + } +} diff --git a/crates/engine/src/cmd/layout/pages.rs b/crates/engine/src/cmd/layout/pages.rs new file mode 100644 index 000000000..0417f1ff6 --- /dev/null +++ b/crates/engine/src/cmd/layout/pages.rs @@ -0,0 +1,535 @@ +use super::margins::set_page_width; +use super::{Result, Value, bad, json, str_param}; + +#[allow(dead_code)] +fn _r(_: Result<()>) {} + +/// Layout › Page Size (the Page tool): resize pages one by one. Left pages grow to the left +/// (the spine stays put); objects move with their page. +pub(super) fn page_size(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageSize", "give `pages` (1-based)")); + } + let (mut w, mut h) = (p.get("width").and_then(Value::as_f64), p.get("height").and_then(Value::as_f64)); + if let Some(name) = str_param(p, "preset") { + let pr = designcraft_doc::build::PRESETS + .iter() + .find(|x| x.name.eq_ignore_ascii_case(name)) + .ok_or_else(|| bad("layout.pageSize", format!("no preset `{name}`")))?; + w = Some(pr.width); + h = Some(pr.height); + } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; + s.edit(|d, _| { + resize_pages_from(d, &pages, w, h, anchor); + Ok(json!({"pages": pages.len()})) + }) +} + +pub(super) fn page_options(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } + Ok(json!({"pages": pages.len()})) + }) +} + +/// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule +/// re-flow their objects by it. +pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: +/// the page grows away from it, so objects keep their place relative to it. +pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); + { + for page in pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); + let old: Vec = sp.pages.iter().map(|x| x.x).collect(); + let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let last = sp.pages.len() - 1; + let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); + let pg = &mut sp.pages[pi]; + let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); + let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); + if let Some(w) = w { + set_page_width(pg, w.max(1.0)); + } + if let Some(h) = h { + pg.height = h.max(1.0); + } + let left = pg.side == designcraft_doc::PageSide::Left; + sp.relayout(); + // Keep the spine: shift the whole spread back when a left page grows. + if left && dw != 0.0 { + for q in &mut sp.pages { + q.x -= dw; + } + } + let new: Vec = sp.pages.iter().map(|x| x.x).collect(); + // Objects follow their page. + let shifts: Vec<(f64, f64, f64)> = + sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); + for it in &mut sp.items { + let cx = it.bounds().center().x; + if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) + && *dx != 0.0 + { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; + } + } + // Page guides move with their page. + for (k, q) in sp.pages.iter_mut().enumerate() { + let dx = new[k] - old[k]; + if dx != 0.0 { + for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + } + } + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; + crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); + } + } +} + +/// File › Document Setup values. +pub(super) fn document_setup(d: &designcraft_doc::Document) -> Value { + let st = &d.settings; + let start = d.sections.iter().find(|x| x.start == 0).and_then(|x| x.start_number).unwrap_or(1); + json!({"width": st.page_width, "height": st.page_height, "pages": d.page_count(), "startPage": start, "facingPages": st.facing_pages, + "binding": if st.right_to_left_binding { "rightToLeft" } else { "leftToRight" }, "intent": st.intent, "bleed": st.bleed, "slug": st.slug, + "margins": d.page(0).map(|p| p.margins), "primaryTextFrame": st.primary_text_frame}) +} + +#[cfg(test)] +mod setup_tests { + use serde_json::json; + + #[test] + fn chapter_numbering_sets_style_and_start() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("layout.chapterNumbering", &json!({})).unwrap(); + assert_eq!(r, json!({"chapterNumber": 1, "label": "1", "style": "arabic", "source": "automatic", "book": null})); + let r = s.execute("layout.chapterNumbering", &json!({"start": 4, "style": "upperRoman"})).unwrap(); + assert_eq!((&r["label"], &r["source"]), (&json!("IV"), &json!("userDefined")), "a start number means Start Chapter Numbering at"); + s.execute("variables.define", &json!({"name": "Chapter", "type": "chapterNumber"})).unwrap(); + let d = &s.doc().unwrap().doc; + let chapter = d.variable("Chapter").unwrap(); + assert_eq!(d.variable_value(chapter, Some(0)).as_deref(), Some("IV"), "the Chapter Number variable uses the style"); + for bad in [json!({"style": "symbols"}), json!({"start": 0}), json!({"start": 100_000}), json!({"source": "nextBook"})] { + assert!(s.execute("layout.chapterNumbering", &bad).is_err(), "{bad}"); + } + s.execute("layout.chapterNumbering", &json!({"source": "sameAsPrevious"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("layout.chapterNumbering", &json!({})).unwrap()["source"], "userDefined", "undoable"); + } + + #[test] + fn document_setup_sets_every_pages_margins_in_one_step() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 3, "margins": 36})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36, 36, 576, 756], "content": "text"})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({})).unwrap(); + assert_eq!(r["margins"], json!({"top": 36.0, "bottom": 36.0, "inside": 36.0, "outside": 36.0})); + let m = json!({"top": 72, "bottom": 54, "inside": 72, "outside": 72}); + let r = s.execute("layout.documentSetup", &json!({"margins": m, "bleed": 9, "adjustLayout": true})).unwrap(); + assert_eq!(r["margins"]["bottom"], 54.0); + let d = &s.doc().unwrap().doc; + assert!( + d.spreads.iter().chain(&d.parents).flat_map(|sp| &sp.pages).all(|pg| pg.margins.top == 72.0 && pg.margins.bottom == 54.0), + "every page and parent page" + ); + let b = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap().bounds(); + assert!((b.y0 - 72.0).abs() < 1e-6, "Adjust Layout: the frame follows the margins ({b:?})"); + // One step: undo brings back the margins and the bleed. + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({})).unwrap(); + assert_eq!((r["margins"]["top"].clone(), r["bleed"][0].clone()), (json!(36.0), json!(0.0))); + for bad in [ + json!({"width": 0}), + json!({"height": -5}), + json!({"width": 1e9}), + json!({"width": "wide"}), + json!({"margins": -1}), + json!({"margins": {"top": 1}}), + ] { + assert!(s.execute("layout.documentSetup", &bad).is_err(), "{bad}"); + } + assert_eq!(s.execute("layout.documentSetup", &json!({})).unwrap()["width"], 612.0, "refused values change nothing"); + } + + #[test] + fn document_setup_pages_start_bleed_slug() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({})).unwrap(); + assert_eq!(r["pages"], 2); + assert_eq!(r["startPage"], 1); + let r = s.execute("layout.documentSetup", &json!({"pages": 5, "startPage": 2, "bleed": 9, "slug": [0, 18, 0, 0], "intent": "web"})).unwrap(); + assert_eq!(r["pages"], 5); + assert_eq!(r["startPage"], 2); + assert_eq!(r["bleed"], json!([9.0, 9.0, 9.0, 9.0])); + assert_eq!(r["slug"], json!([0.0, 18.0, 0.0, 0.0])); + assert_eq!(r["intent"], "web"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_name(0), "2"); + assert_eq!(d.page(0).unwrap().side, designcraft_doc::PageSide::Left); + assert_eq!(d.spreads[0].pages.len(), 2, "pages 2–3 form the first spread"); + let r = s.execute("layout.documentSetup", &json!({"pages": 3})).unwrap(); + assert_eq!(r["pages"], 3); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn right_to_left_binding_reverses_spreads() { + use designcraft_doc::PageSide::{Left, Right}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + // A frame on page 2 stays on page 2. + let w = s.doc().unwrap().doc.settings.page_width; + let f = s.execute("frame.create", &json!({"rect": [w + 72.0, 72, w + 200.0, 200], "content": "text", "text": "two"})).unwrap(); + let r = s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); + assert_eq!(r["binding"], "rightToLeft"); + let d = &s.doc().unwrap().doc; + let shape: Vec> = d.spreads.iter().map(|sp| sp.pages.iter().map(|p| (p.side, p.x)).collect()).collect(); + // Page 1 alone on the left; pages 2–3 with 2 on the right; page 4 alone on the right. + assert_eq!(shape, vec![vec![(Left, 0.0)], vec![(Right, w), (Left, 0.0)], vec![(Right, 0.0)]]); + let (si, _) = d.page_loc(1).unwrap(); + assert_eq!(si, 1); + let it = d.item(designcraft_doc::ItemId(f["id"].as_u64().unwrap())).unwrap(); + assert_eq!(d.spreads[si].page_at_x(it.bounds().center().x), Some(0), "the frame is still on page 2"); + assert!(it.bounds().x0 >= w, "page 2 is the right page now"); + d.check().unwrap(); + // And back. + s.execute("layout.documentSetup", &json!({"binding": "leftToRight"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.spreads[1].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Left, 0.0), (Right, w)]); + } + + /// Bound right to left, a parent spread lists its right page first; each document page still + /// shows the parent page on its own side, at its own position, also after an IDML round trip. + #[test] + fn right_to_left_binding_shows_the_parent_page_of_the_same_side() { + use designcraft_doc::PageSide::{Left, Right}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": "rightToLeft"})).unwrap(); + let w = s.doc().unwrap().doc.settings.page_width; + let parent = json!({"kind": "parent", "index": 0}); + for (rect, text) in [(json!([72, 36, 200, 60]), "left head"), (json!([w + 72.0, 36, w + 200.0, 60]), "right head")] { + s.execute("frame.create", &json!({"spread": parent, "rect": rect, "content": "text", "text": text})).unwrap(); + } + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.parents[0].pages.iter().map(|p| (p.side, p.x)).collect::>(), vec![(Right, w), (Left, 0.0)]); + let docs = [(*d).clone(), designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap()]; + for d in docs { + let mut s = crate::Session::new(); + s.add_document(crate::DocState::new(d, None)); + for (page, side, text) in [(0, Left, "left head"), (1, Right, "right head"), (2, Left, "left head"), (3, Right, "right head")] { + let r = s.execute("layout.overrideParentItems", &json!({"page": page})).unwrap(); + let d = &s.doc().unwrap().doc; + let ids = r["ids"].as_array().unwrap(); + assert_eq!(ids.len(), 1, "page {}", page + 1); + let it = d.item(designcraft_doc::ItemId(ids[0].as_u64().unwrap())).unwrap(); + let story = d.story(it.text_frame().unwrap().story).unwrap(); + assert_eq!((d.page(page).unwrap().side, story.text.trim_end()), (side, text), "page {}", page + 1); + assert_eq!(d.page_of_item(it.id), Some(page), "page {}", page + 1); + } + } + } + + /// Multi-column frames filled the left column first in a right-to-left document (#100): + /// stories made in a right-to-left-bound document start their columns on the right. + #[test] + fn new_stories_follow_the_binding_direction() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let text = (1..=12).map(|i| format!("الفقرة {i}")).collect::>().join("\n"); + for (binding, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [40, 170, 555, 260], "content": "text", "text": text})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [f["id"]], "columns": 2, "gutter": 20})).unwrap(); + let sid = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + let st = s.doc().unwrap(); + assert_eq!(st.doc.story(sid).unwrap().direction, expected, "{binding}"); + let cs = s.cache.get(&st.doc, sid, None); + let fr = &cs.frames[0]; + let (first, last) = (fr.lines.first().unwrap(), fr.lines.last().unwrap()); + assert!(fr.columns.len() == 2 && first.x0 != last.x0, "the text fills both columns: {:?}", fr.columns); + assert_eq!(first.x0 > last.x0, expected == RightToLeft, "{binding}: the first line is in the first column"); + // An empty frame given text content starts a story too. + let g = s.execute("frame.create", &json!({"rect": [40, 400, 555, 500]})).unwrap(); + s.execute("object.content", &json!({"ids": [g["id"]], "type": "text"})).unwrap(); + let d = &s.doc().unwrap().doc; + let tf = d.item(designcraft_doc::ItemId(g["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(d.story(tf).unwrap().direction, expected, "{binding}: object.content"); + } + } +} + +#[cfg(test)] +mod page_size_tests { + use serde_json::json; + + #[test] + fn page_size_per_page_keeps_objects_on_their_page() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + // Page 3 is the right page of spread 2 (pages 2–3); an object on it. + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(2).unwrap(); + let x0 = d.spreads[si].pages[pi].x; + let id = s.execute("frame.create", &json!({"spread": si, "rect": [x0 + 100.0, 100, x0 + 200.0, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pageSize", &json!({"pages": [2], "width": 300, "height": 500})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let left = &d.spreads[si].pages[0]; + assert_eq!((left.width, left.height), (300.0, 500.0)); + assert_eq!(d.spreads[si].pages[1].x, x0, "the spine stays"); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.bounds().x0, x0 + 100.0); + assert_eq!(d.page_of_item(it.id), Some(2)); + s.execute("layout.pageSize", &json!({"pages": [3], "preset": "A4"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!((d.spreads[si].pages[1].width - 595.276).abs() < 0.01); + assert!(s.execute("layout.pageSize", &json!({"pages": [9], "width": 100})).is_err()); + } +} + +#[cfg(test)] +mod spread_tests { + use serde_json::json; + + #[test] + fn three_page_spread_survives_page_inserts() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 5})).unwrap(); + // [1] [2 3] [4 5]: page 4 joins spread 1 → [1] [2 3 4] [5]. + let d = s.doc().unwrap().doc.clone(); + let (si, pi) = d.page_loc(3).unwrap(); + let x = d.spreads[si].pages[pi].x; + let id = s.execute("frame.create", &json!({"spread": si, "rect": [x + 50.0, 50, x + 150.0, 150]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pages.toSpread", &json!({"page": 4, "spread": 1})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.spreads[1].pages.len(), 3); + assert!(!d.spreads[1].allow_shuffle); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(id)), Some(3), "the object went with its page"); + // Adding a page at the start reshuffles the others but keeps the 3-page spread. + s.execute("layout.pages.insert", &json!({"after": 0, "count": 1})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.spreads.iter().any(|sp| sp.pages.len() == 3 && !sp.allow_shuffle)); + assert_eq!(d.page_count(), 6); + d.check().unwrap(); + } +} + +#[cfg(test)] +mod page_numbering_view_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn section_and_absolute_page_numbering_view() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 1, "startNumber": 1, "style": "lowerRoman"})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["i", "ii", "1", "2"]); + assert_eq!(s.resolve_page("ii"), Some(1)); + assert_eq!(s.resolve_page("1"), Some(2), "section name first"); + assert_eq!(s.resolve_page("+1"), Some(0), "+n is a position"); + s.execute("prefs.set", &json!({"absolutePageNumbers": true})).unwrap(); + assert_eq!((0..4).map(|i| s.page_label(i)).collect::>(), ["1", "2", "3", "4"]); + assert_eq!(s.resolve_page("1"), Some(0)); + assert_eq!(s.resolve_page("9"), None); + } + + #[test] + fn section_start_numbers_are_bounded() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + for n in [json!(0), json!(100_000), json!(u32::MAX), json!(u64::from(u32::MAX) + 2), json!(-1), json!(2.5), json!("7")] { + let e = s.execute("layout.section", &json!({"page": 1, "startNumber": n})).unwrap_err().to_string(); + assert!(e.contains("startNumber"), "{n}: {e}"); + assert!(s.execute("layout.documentSetup", &json!({"startPage": n})).is_err(), "{n}"); + } + assert_eq!((0..2).map(|i| s.page_label(i)).collect::>(), ["1", "2"]); + let r = s.execute("layout.section", &json!({"page": 1, "startNumber": 99_999})).unwrap(); + assert_eq!(r["names"], json!(["99999", "100000"])); + } + + #[test] + fn numbering_options_apply_in_one_undo_step_or_not_at_all() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + let names = |s: &crate::Session| (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect::>(); + let r = s + .execute( + "layout.numberingOptions", + &json!({"page": 3, "startNumber": 5, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "chapter": {"start": 4, "style": "upperRoman"}}), + ) + .unwrap(); + assert_eq!((r["names"].clone(), r["chapter"].clone()), (json!(["1", "2", "A-v", "A-vi"]), json!("IV"))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&s), ["1", "2", "3", "4"], "one undo reverts the section…"); + assert_eq!(s.doc().unwrap().doc.chapter_label(), "1", "…and the chapter numbering"); + // A refused part leaves the other undone too. + for bad in [ + json!({"page": 3, "startNumber": 5, "chapter": {"start": 100_000}}), + json!({"page": 3, "startNumber": 4_294_967_295u64, "chapter": {"start": 2}}), + json!({"page": 3, "prefix": "A+B", "chapter": {"start": 2}}), + json!({"page": 9, "chapter": {"start": 2}}), + ] { + assert!(s.execute("layout.numberingOptions", &bad).is_err(), "{bad}"); + assert_eq!(names(&s), ["1", "2", "3", "4"], "{bad}"); + assert_eq!(s.doc().unwrap().doc.settings.chapter_source, designcraft_doc::ChapterSource::Automatic, "{bad}"); + } + // Removing a section keeps the chapter part. + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + s.execute("layout.numberingOptions", &json!({"page": 3, "remove": true, "chapter": {"source": "userDefined", "start": 2}})).unwrap(); + assert_eq!(names(&s), ["1", "2", "3", "4"]); + assert_eq!(s.doc().unwrap().doc.chapter_label(), "2"); + } + + #[test] + fn section_prefixes_are_short_and_avoid_plus_and_comma() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + for prefix in ["A-", "Appendix", "§ 1"] { + s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).unwrap(); + } + for prefix in ["Appendix1", "A+", "1,2"] { + assert!(s.execute("layout.section", &json!({"page": 2, "prefix": prefix})).is_err(), "{prefix}"); + } + assert!(s.execute("layout.section", &json!({"page": 2, "style": "symbols"})).is_err()); + assert_eq!(s.doc().unwrap().doc.sections.iter().find(|x| x.start == 1).unwrap().prefix, "§ 1", "a refused prefix changes nothing"); + } + + /// `layout.section` edits one named setting; the others stay as the section was (#717). + #[test] + fn updating_a_section_keeps_settings_the_command_does_not_name() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4})).unwrap(); + s.execute( + "layout.section", + &json!({"page": 2, "startNumber": 1, "style": "lowerRoman", "prefix": "A-", "includePrefix": true, "marker": "First"}), + ) + .unwrap(); + assert_eq!(s.page_label(1), "A-i"); + s.execute("layout.section", &json!({"page": 2, "marker": "Second"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sec = d.sections.iter().find(|x| x.start == 1).expect("the page-2 section is still the only one there"); + assert_eq!(sec.marker, "Second"); + assert_eq!(sec.start_number, Some(1), "startNumber is kept"); + assert_eq!(sec.style, designcraft_doc::NumberStyle::LowerRoman, "style is kept"); + assert_eq!(sec.prefix, "A-", "prefix is kept"); + assert!(sec.include_prefix, "includePrefix is kept"); + assert_eq!(s.page_label(1), "A-i"); + } +} + +#[cfg(test)] +mod spread_rotation_input_tests { + use serde_json::json; + + #[test] + fn quarter_turns_work_and_other_angles_are_rejected_without_mutation() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rotation = |s: &crate::Session| s.doc().unwrap().doc.spreads[0].pages[0].view_rotation; + for bad in [json!(45), json!(-45), json!(91), json!("90"), json!(0.5)] { + assert!(s.execute("layout.rotateSpreadView", &json!({"angle": bad})).is_err(), "{bad}"); + assert_eq!(rotation(&s), 0, "invalid input must not change the view"); + } + s.execute("layout.rotateSpreadView", &json!({"angle": 90})).unwrap(); + assert_eq!(rotation(&s), 1); + s.execute("layout.rotateSpreadView", &json!({"angle": -90})).unwrap(); + assert_eq!(rotation(&s), 0); + s.execute("layout.rotateSpreadView", &json!({"angle": 180})).unwrap(); + assert_eq!(rotation(&s), 2); + s.execute("layout.rotateSpreadView", &json!({"angle": 0})).unwrap(); + assert_eq!(rotation(&s), 0, "zero resets the spread view"); + } +} + +#[cfg(test)] +mod insert_count_tests { + use serde_json::json; + + use crate::Session; + + /// A count that is given but is not a number of pages used to insert one page anyway: + /// `{"count": 0}` added a page, `-1` and `1.5` added one each, and `10000` was quietly capped + /// to 9999. A position past the last page appends, which is deliberate. + #[test] + fn a_page_count_insert_pages_cannot_mean_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let total = |s: &mut Session| s.execute("layout.pages.insert", &json!({"count": 1})).unwrap()["total"].as_u64().unwrap(); + assert_eq!(total(&mut s), 3, "one page added"); + + for params in [json!({"count": 0}), json!({"count": -1}), json!({"count": 1.5}), json!({"count": 10000}), json!({"count": "two"})] { + let e = s.execute("layout.pages.insert", ¶ms).expect_err("refused").to_string(); + assert!(e.contains("1 to 9999 pages"), "{params}: {e}"); + } + // Nothing was inserted by any of those. + assert_eq!(s.doc().unwrap().doc.page_count(), 3); + // A position past the last page still appends, which `insert_pages_bounds.rs` pins. + let r = s.execute("layout.pages.insert", &json!({"after": 99})).unwrap(); + assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(1), Some(4))); + // The positions it does have still work: after the first page of three. + let r = s.execute("layout.pages.insert", &json!({"count": 2, "after": 0})).unwrap(); + assert_eq!((r["pages"].as_u64(), r["total"].as_u64()), (Some(2), Some(6))); + } +} diff --git a/crates/engine/src/cmd/layout/parents.rs b/crates/engine/src/cmd/layout/parents.rs new file mode 100644 index 000000000..f53d774f4 --- /dev/null +++ b/crates/engine/src/cmd/layout/parents.rs @@ -0,0 +1,458 @@ +use super::{Result, SpreadId, Value, bad, json}; + +/// A parent spread named by prefix, label (`A-Parent`) or index. +pub(super) fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { + match v { + Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < d.parents.len()), + Value::String(s) => d.parents.iter().position(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)), + _ => None, + } +} + +/// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or +/// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent); +/// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never +/// clears a page's parent. +pub(super) fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result>> { + let Some(v) = v else { return Ok(None) }; + match v { + Value::Null => Ok(Some(None)), + Value::String(s) if s == "[None]" => Ok(Some(None)), + Value::Number(n) => { + let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?; + d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}"))) + } + Value::String(s) => { + let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec { + d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect() + }; + let mut hits = by(&|i| i.label() == *s); + if hits.is_empty() { + hits = by(&|i| i.prefix == *s); + } + match hits.as_slice() { + [id] => Ok(Some(Some(*id))), + [] => Err(bad(cmd, format!("no parent named `{s}`"))), + _ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))), + } + } + _ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")), + } +} + +/// Layout › Pages › Parent Options: Based on Parent. A base that would make a cycle is refused. +pub(super) fn parent_options(s: &mut crate::Session, p: &Value) -> Result { + const CMD: &str = "layout.parents.options"; + let d = &s.doc()?.doc; + let Some(Some(id)) = parent_ref(CMD, d, p.get("parent"))? else { return Err(bad(CMD, "`parent` names the parent to change")) }; + let pi = d.parent_index(id).ok_or_else(|| bad(CMD, format!("no parent with id {}", id.0)))?; + let base = parent_ref(CMD, d, p.get("basedOn"))?.ok_or_else(|| bad(CMD, "missing `basedOn` (a parent, or null for [None])"))?; + let label = |d: &designcraft_doc::Document, i: usize| d.parents.get(i).and_then(|x| x.parent.as_ref()).map(|i| i.label()); + if let Some(b) = base + && d.parent_base_cycles(pi, b) + { + let (name, b) = (label(d, pi).unwrap_or_default(), d.parent_index(b).and_then(|i| label(d, i)).unwrap_or_default()); + return Err(bad(CMD, format!("{name} cannot be based on {b}: that would make a cycle"))); + } + let out = json!({"parent": label(d, pi), "basedOn": base.and_then(|b| d.parent_index(b)).and_then(|i| label(d, i))}); + if d.parents.get(pi).and_then(|x| x.parent.as_ref()).and_then(|i| i.based_on) == base { + return Ok(out); + } + s.edit(|d, _| { + let info = d.parents.get_mut(pi).map(std::sync::Arc::make_mut).and_then(|x| x.parent.as_mut()).ok_or_else(|| bad(CMD, "no such parent"))?; + info.based_on = base; + Ok(out) + }) +} + +#[cfg(test)] +mod parent_margin_tests { + use designcraft_doc::{Document, Margins}; + use serde_json::json; + + use crate::Session; + + fn margins(d: &Document) -> Vec<(Margins, u32, bool)> { + (0..d.page_count()).filter_map(|i| d.page(i)).map(|p| (p.margins, p.columns.count, p.local_margins)).collect() + } + + #[test] + fn document_setup_margins_let_pages_follow_their_parent_page_again() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"pages": [1], "margins": 100})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100, "columns": 3})).unwrap(); + s.execute("layout.documentSetup", &json!({"margins": 20})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + let twenty = Margins::uniform(20.0); + assert_eq!(m, vec![(twenty, 1, false), (twenty, 1, false), (twenty, 3, true)], "own columns stay local"); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 50})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[1], (Margins::uniform(50.0), 1, false), "page 2 follows the parent page again"); + assert_eq!(m[2], (twenty, 3, true)); + } + + #[test] + fn pages_follow_their_parent_pages_margins_unless_set_locally() { + let mut s = Session::new(); + // Facing: [1] [2 3]; page 3 gets its own margins. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap(); + // A's left page: only the left document page follows. + let left = json!({"top": 10, "bottom": 20, "inside": 30, "outside": 40}); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "pages": [0], "margins": left, "columns": 2})).unwrap(); + let l = Margins { top: 10.0, bottom: 20.0, inside: 30.0, outside: 40.0 }; + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m, vec![(Margins::uniform(36.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); + // A's right page: page 1 follows, page 3 keeps its own. + s.execute("layout.marginsAndColumns", &json!({"parent": "A-Parent", "pages": [1], "margins": 50})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m, vec![(Margins::uniform(50.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]); + // A page inserted after page 1 pushes page 2 to the right: it takes the right parent page's. + s.execute("layout.pages.insert", &json!({"after": 0, "count": 1, "parent": "A"})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[1], (l, 2, false), "the new left page"); + assert_eq!(m[2], (Margins::uniform(50.0), 1, false), "old page 2, now a right page"); + assert_eq!(m[3], (Margins::uniform(100.0), 1, true)); + assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err()); + } + + #[test] + fn applying_a_parent_gives_its_margins_and_local_margins_stay_local() { + let mut s = Session::new(); + // Facing: [1] [2 3]. + s.execute("file.new", &json!({"pages": 3})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "B", "margins": 70, "columns": 3})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "the page takes B's margins"); + // Page 3 is given margins equal to A's: they are its own all the same. + s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 36})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 20})).unwrap(); + let m = margins(&s.doc().unwrap().doc); + assert_eq!(m[0], (Margins::uniform(20.0), 1, false)); + assert_eq!(m[1], (Margins::uniform(70.0), 3, false), "B's page is not A's"); + assert_eq!(m[2], (Margins::uniform(36.0), 1, true)); + // Back to A: page 2 follows A again. + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "A"})).unwrap(); + assert_eq!(margins(&s.doc().unwrap().doc)[1], (Margins::uniform(20.0), 1, false)); + } + + #[test] + fn parent_guides_show_on_pages_but_stay_on_the_parent() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + // A vertical guide 100 pt into A's right page (x = 612 in the parent spread). + s.execute("guide.add", &json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712})).unwrap(); + let d = &s.doc().unwrap().doc; + let right = d.page(0).unwrap(); + let shown: Vec = d.parent_guides(right).map(|g| g.position).collect(); + assert_eq!(shown, vec![100.0], "moved to the document page's place"); + assert_eq!(d.parent_guides(d.page(1).unwrap()).count(), 0, "the left page shows A's left page's guides"); + // Not the document page's own: not listed, moved or deleted from there. + assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap(), json!([])); + assert!(s.execute("guide.move", &json!({"spread": 0, "page": 0, "index": 0, "position": 50})).is_err()); + assert!(s.execute("guide.delete", &json!({"spread": 0, "page": 0, "index": 0})).is_err()); + } +} + +#[cfg(test)] +mod parent_tests { + use serde_json::json; + + fn parent_of(s: &crate::Session, page: usize) -> Option { + s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0) + } + + #[test] + fn parents_with_one_prefix_stay_distinct() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); + assert_eq!(parent_of(&s, 0), Some(b)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap(); + assert_eq!(parent_of(&s, 1), Some(b)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap(); + assert_eq!(parent_of(&s, 1), Some(a)); + // A prefix two parents share, or a name no parent has, is an error and changes nothing. + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err()); + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err()); + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err()); + assert_eq!(parent_of(&s, 1), Some(a)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap(); + assert_eq!(parent_of(&s, 1), None); + } + + #[test] + fn edit_parents_shows_the_named_parent() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let ids: Vec = + ["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect(); + s.ui_requests.clear(); + let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap(); + assert_eq!(r, json!({"on": true, "parent": ids[2]})); + assert!(s.doc().unwrap().editing_parents); + let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap(); + let shown: Vec<_> = s + .ui_requests + .iter() + .filter_map(|r| match r { + crate::UiRequest::View { params } => params.get("showSpread").cloned(), + _ => None, + }) + .collect(); + assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]); + let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap(); + assert_eq!(r["parent"], ids[1]); + assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err()); + let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap(); + assert_eq!(r, json!({"on": false, "parent": null})); + assert!(!s.doc().unwrap().editing_parents); + } + + /// A new parent takes the first page's columns, dragged column guides included: the right + /// parent page as the first (right) page has them, the left one mirrored across the spine. + /// `document.inspect` reports where those columns are. + #[test] + fn a_new_parent_keeps_dragged_columns() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let dragged = designcraft_doc::Columns { count: 2, gutter: 12.0, positions: Some(vec![0.0, 100.0, 130.0, 540.0]) }; + // There is no command for dragging a column guide yet, so set what an import would leave. + s.edit(|d, _| { + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[0]).pages[0]; + (pg.columns, pg.local_margins) = (dragged.clone(), true); + Ok(()) + }) + .unwrap(); + let id = s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap(); + let d = &s.doc().unwrap().doc; + let parent = d.parents.iter().find(|p| p.id.0 == id).unwrap(); + assert_eq!(parent.pages.len(), 2); + let side = |s: designcraft_doc::PageSide| parent.pages.iter().find(|p| p.side == s).unwrap(); + let (left, right) = (side(designcraft_doc::PageSide::Left), side(designcraft_doc::PageSide::Right)); + assert_eq!(right.columns, dragged); + assert_eq!(right.column_rects().iter().map(|r| r.width()).collect::>(), vec![100.0, 410.0]); + assert_eq!(left.columns.positions, Some(vec![0.0, 410.0, 440.0, 540.0])); + assert_eq!(left.column_rects().iter().map(|r| r.width()).collect::>(), vec![410.0, 100.0]); + + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + let page = &inspect["spreads"][0]["pages"][0]; + assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}"); + assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}"); + } + + fn columns_of(s: &crate::Session, abs: usize) -> (Option>, bool) { + let pg = s.doc().unwrap().doc.page(abs).unwrap().clone(); + (pg.columns.positions, pg.local_margins) + } + + /// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked. + fn three_columns(pages: u64) -> crate::Session { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": pages})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap(); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + s + } + + /// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring + /// gutter and the margin, makes a following page's columns its own, and undoes in one step. + #[test] + fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() { + let mut s = three_columns(1); + assert_eq!(columns_of(&s, 0), (None, false), "the page follows A"); + let mv = + |s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x})); + let r = mv(&mut s, 0, 236.0).unwrap(); + assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0})); + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0])); + // Past the next gutter: it stops there, and the middle column has no width. + assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0)); + // Past the right margin: the last gutter stops 12 pt (its width) inside it. + assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0])); + // Past the left margin. + assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0])); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag"); + } + + /// On a parent page the drag changes the parent, and the document pages that follow it get + /// the new columns; a page with columns of its own keeps them. + #[test] + fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() { + let mut s = three_columns(3); + // Page 1, the left page of spread 1, gets columns of its own. + let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0; + s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap(); + let own = columns_of(&s, 1); + assert!(own.1 && own.0.is_some()); + let d = &s.doc().unwrap().doc; + // A's right page: the one page 0 (a right page) and page 2 follow. + let x0 = d.parents[0].pages[1].margin_rect().x0; + s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap(); + let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0]; + assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone())); + assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false)); + assert_eq!(columns_of(&s, 2), (Some(moved), false)); + assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own"); + // The same parent page by spread reference. + let spread = json!({"kind": "parent", "index": 0}); + s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap(); + assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0); + } + + /// A drag's preview in one document is put back there when another document became active. + #[test] + fn a_column_guide_preview_is_cancelled_in_its_own_document() { + let mut s = three_columns(1); + let uid = s.doc().unwrap().uid; + s.begin_interaction("Move Column Guide").unwrap(); + s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + s.cancel_interaction_of(uid); + s.set_active(0); + assert!(s.doc().unwrap().interaction.is_none()); + assert_eq!(columns_of(&s, 0), (None, false)); + } + + #[test] + fn move_column_guide_refuses_while_locked_and_hostile_params() { + let mut s = three_columns(1); + s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap(); + assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err()); + assert_eq!(columns_of(&s, 0), (None, false)); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + for p in [ + json!({"page": 0, "gutter": 0}), + json!({"page": 0, "gutter": 0, "position": f64::NAN}), + json!({"page": 0, "gutter": 0, "position": "200"}), + json!({"page": 0, "gutter": 2, "position": 200}), + json!({"page": 0, "gutter": u64::MAX, "position": 200}), + json!({"page": 0, "gutter": -1, "position": 200}), + json!({"page": 0, "gutter": 0.5, "position": 200}), + json!({"page": 9, "gutter": 0, "position": 200}), + json!({"page": u64::MAX, "gutter": 0, "position": 200}), + json!({"gutter": 0, "position": 200}), + json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}), + json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}), + json!({"page": 0, "gutter": 0, "position": f64::MAX}), + ] { + let r = s.execute("layout.moveColumnGuide", &p); + // A huge but finite position is clamped to the margin; everything else is refused. + assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}"); + } + } +} + +#[cfg(test)] +mod parent_base_tests { + use serde_json::{Value, json}; + + fn rect(s: &mut crate::Session, spread: Value, r: [f64; 4], swatch: &str) -> u64 { + let id = s.execute("frame.create", &json!({"spread": spread, "rect": r, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": swatch})).unwrap(); + id + } + + fn page(s: &crate::Session, abs: usize) -> designcraft_render::Rendered { + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&s.doc().unwrap().doc, &s.cache, abs, 1.0, false, &Default::default()).unwrap() + } + + /// Parent A (black), parent B based on A (cyan, half over A's square), and a page item (paper) + /// over B's: the page draws A, then B, then its own items. + #[test] + fn based_on_parent_draws_base_items_under_parent_items() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "pages": 2, "facingPages": false, "margins": 0})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Opening"})).unwrap(); + rect(&mut s, json!({"kind": "parent", "index": 0}), [0.0, 0.0, 200.0, 200.0], "[Black]"); + rect(&mut s, json!({"kind": "parent", "index": 1}), [100.0, 0.0, 300.0, 200.0], "C=100 M=0 Y=0 K=0"); + rect(&mut s, json!(1), [150.0, 0.0, 190.0, 40.0], "[Paper]"); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap(); + let black = |p: [u8; 4]| p[0] < 80 && p[1] < 80 && p[2] < 80; + let cyan = |p: [u8; 4]| p[0] < 80 && p[1] > 120 && p[2] > 180; + let paper = |p: [u8; 4]| p[0] > 240 && p[1] > 240 && p[2] > 240; + assert!(paper(page(&s, 1).pixel(50, 100)), "B alone does not show A's items"); + + let r = s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "A"})).unwrap(); + assert_eq!(r, json!({"parent": "B-Opening", "basedOn": "A-Parent"})); + let img = page(&s, 1); + assert!(black(img.pixel(50, 100)), "A's item: {:?}", img.pixel(50, 100)); + assert!(cyan(img.pixel(150, 150)), "B's item over A's: {:?}", img.pixel(150, 150)); + assert!(paper(img.pixel(170, 20)), "the page's item over B's: {:?}", img.pixel(170, 20)); + assert!(black(page(&s, 0).pixel(50, 100)) && !cyan(page(&s, 0).pixel(250, 100)), "page 1 uses A only"); + // Parent B itself shows A's items under its own. + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let placed = [designcraft_render::Placed { spread: designcraft_doc::SpreadRef::Parent(1), xf: designcraft_geom::Affine::IDENTITY }]; + let opts = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = rr.render(&s.doc().unwrap().doc, &s.cache, &placed, (400, 400), designcraft_geom::Affine::IDENTITY, &opts); + assert!(black(img.pixel(50, 100)) && cyan(img.pixel(150, 150)), "{:?} {:?}", img.pixel(50, 100), img.pixel(150, 150)); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["parents"][1]["basedOn"], "A-Parent"); + assert_eq!(inspect["parents"][0]["basedOn"], Value::Null); + + // Cycles are refused. + assert!(s.execute("layout.parents.options", &json!({"parent": "A", "basedOn": "B"})).is_err()); + assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "B-Opening"})).is_err()); + assert!(s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": "Z"})).is_err()); + + s.execute("edit.undo", &json!({})).unwrap(); + assert!(paper(page(&s, 1).pixel(50, 100)), "undo clears Based on Parent"); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("layout.parents.options", &json!({"parent": "B", "basedOn": null})).unwrap(); + assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["parents"][1]["basedOn"], Value::Null); + } + + /// A's page-number marker shown on parent XYZ (based on A) reads XYZ, as markers on XYZ's own items do. + #[test] + fn base_parent_markers_read_the_showing_parent() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "XYZ"})).unwrap(); + s.execute("layout.parents.options", &json!({"parent": "XYZ", "basedOn": "A"})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sid = d.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let glyphs = |on| -> usize { + let cs = s.cache.get_on(&d, sid, on); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(glyphs(designcraft_compose::ShownOn::Own), 1, "A"); + assert_eq!(glyphs(designcraft_compose::ShownOn::Parent(1)), 3, "XYZ"); + } + + /// A file whose parents are based on each other opens, with the cycle broken and reported. + #[test] + fn opening_a_parent_cycle_breaks_it_with_a_warning() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap(); + let mut d = s.doc().unwrap().doc.as_ref().clone(); + let (a, b) = (d.parents[0].id, d.parents[1].id); + std::sync::Arc::make_mut(&mut d.parents[0]).parent.as_mut().unwrap().based_on = Some(b); + std::sync::Arc::make_mut(&mut d.parents[1]).parent.as_mut().unwrap().based_on = Some(a); + let b64 = crate::cmd::base64_encode(&crate::cmd::to_bytes(&d).unwrap()); + let r = s.execute("file.openBytes", &json!({"name": "cycle", "base64": b64})).unwrap(); + assert!(r["warnings"].to_string().contains("cycle"), "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a)); + } +} diff --git a/crates/engine/src/cmd/layout/registry.rs b/crates/engine/src/cmd/layout/registry.rs new file mode 100644 index 000000000..4826f9273 --- /dev/null +++ b/crates/engine/src/cmd/layout/registry.rs @@ -0,0 +1,742 @@ +use super::layers::set_layer; +use super::margins::{adjust_layout, column_guides_unlocked, margin_boxes, move_column_guide, set_margins_columns, set_page_width}; +use super::pages::{document_setup, page_options, page_size}; +use super::parents::{parent_options, parent_ref, parent_spread_index}; +use super::sections::{ChapterEdit, SectionEdit, chapter_numbering, start_number}; +use super::{CommandSpec, LAYER_COLORS, Layer, LayerId, Margins, SpreadId, Value, bad, cmd, has_doc, json, ok, str_param}; + +pub fn specs() -> Vec { + vec![ + cmd!( + "layout.pages.insert", + "Insert Pages…", + ["Layout", "Pages"], + Some("Cmd+Shift+P"), + "{count?: 1 (1 to 9999), after?: 0-based page index (default: the last page), parent?: id|\"A-Parent\"|\"A\"|null}", + has_doc, + |s, p| { + const ID: &str = "layout.pages.insert"; + // A count that is given but is not a number of pages used to insert one page: + // `0`, `-1` and `1.5` all came out as 1, and 10000 was quietly capped. + let count = match p.get("count").filter(|v| !v.is_null()) { + None => 1, + Some(v) => match v.as_u64().filter(|n| (1..=9999).contains(n)).map(|n| n as usize) { + Some(n) => n, + None => return Err(bad(ID, format!("count {v}: 1 to 9999 pages"))), + }, + }; + let st = s.doc()?; + let n = st.doc.page_count(); + // An `after` past the last page appends, which `insert_after_u64_max_appends_at_the_end` + // pins deliberately. + let after = p.get("after").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)).unwrap_or(n.saturating_sub(1)); + let parent = parent_ref(ID, &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); + s.edit(|d, _| { + let ids = d.insert_pages(Some(after), count, parent)?; + Ok(json!({"pages": ids.len(), "total": d.page_count()})) + }) + } + ), + cmd!( + "layout.rotateSpreadView", + "Rotate Spread", + [], + None, + "{spread?: index (0), angle: 90 (clockwise) | -90 | 180 | 0 (clear)} — turn one spread on screen (output is unaffected)", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let angle = match p.get("angle") { + None => 90, + Some(v) => v.as_i64().ok_or_else(|| bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees"))?, + }; + if angle % 90 != 0 { + return Err(bad("layout.rotateSpreadView", "angle must be an integer multiple of 90 degrees")); + } + s.edit(|d, _| { + let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.rotateSpreadView", "no such spread"))?; + let pg = std::sync::Arc::make_mut(sp).pages.first_mut().ok_or_else(|| bad("layout.rotateSpreadView", "empty spread"))?; + pg.view_rotation = if angle == 0 { 0 } else { ((pg.view_rotation as i64 + angle.div_euclid(90)).rem_euclid(4)) as u8 }; + Ok(json!({"rotation": pg.view_rotation as u32 * 90})) + }) + } + ), + cmd!("layout.pages.delete", "Delete Pages", ["Layout", "Pages"], None, "{pages: [index]}", has_doc, |s, p| { + let pages: Vec = p + .get("pages") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) + .unwrap_or_default(); + s.edit(|d, sel| { + d.delete_pages(&pages)?; + sel.items.retain(|i| d.item(*i).is_some()); + sel.text = sel.text.filter(|t| d.story(t.story).is_some()); + ok() + }) + }), + cmd!("layout.pages.move", "Move Pages…", ["Layout", "Pages"], None, "{from, to}", has_doc, |s, p| { + let from = p.get("from").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing from"))? as usize; + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.move", "missing to"))? as usize; + s.edit(|d, _| { + d.move_page(from, to)?; + ok() + }) + }), + cmd!( + "layout.pages.duplicateSpread", + "Duplicate Spread", + ["Layout", "Pages"], + None, + "{spread, atEnd?: bool (Duplicate Spread at End of Document; the Pages panel's New Page button takes a dropped page there), to?: page index the copied pages move to in the same undo step (the Pages panel's Alt-drag)} → {page: the copy's first page (0-based)} — the spread's pages (size, margins, columns, guides, liquid rule) and items", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let at_end = p.get("atEnd").and_then(Value::as_bool).unwrap_or(false); + let to = p.get("to").and_then(Value::as_u64).map(|t| t as usize); + s.edit(|d, _| { + let sp = d.spreads.get(si).cloned().ok_or_else(|| bad("layout.pages.duplicateSpread", "no such spread"))?; + // The copies land at `to`..`to + n` of the document with them in it. + if to.is_some_and(|t| t > d.page_count()) { + return Err(bad("layout.pages.duplicateSpread", "no such page")); + } + let after = if at_end { d.page_count() - 1 } else { d.first_page_of_spread(si) + sp.pages.len() - 1 }; + let parent = sp.pages.first().and_then(|p| p.parent); + d.insert_pages(Some(after), sp.pages.len(), parent)?; + // The copies are the pages they copy: size, margins, columns, guides, liquid rule. + for (k, src) in sp.pages.iter().enumerate() { + let Some((nsi, npi)) = d.page_loc(after + 1 + k) else { continue }; + let nsp = std::sync::Arc::make_mut(&mut d.spreads[nsi]); + let pg = &mut nsp.pages[npi]; + let dx = pg.x - src.x; + // A copy on the other side of the spine has its dragged columns mirrored. + let columns = pg.columns_from(src); + (pg.width, pg.height, pg.margins, pg.columns, pg.liquid) = (src.width, src.height, src.margins, columns, src.liquid); + pg.guides = src.guides.clone(); + for g in pg.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + nsp.relayout(); + } + // Copy items onto the new spread(s) at the same positions. + let src = d.clone(); + let ids: Vec<_> = sp.items.iter().map(|i| i.id).collect(); + let (nsi, _) = d.page_loc(after + 1).ok_or_else(|| bad("layout.pages.duplicateSpread", "insert failed"))?; + crate::cmd::object::duplicate_from(d, &src, &ids, designcraft_doc::SpreadRef::Doc(nsi), designcraft_geom::Vec2::ZERO)?; + let first = after + 1; + let Some(to) = to else { return Ok(json!({"page": first})) }; + // Moving a page shifts the ones between; order the moves so each copy keeps + // its place among the others. + let n = sp.pages.len(); + if first < to { + for k in (0..n).rev() { + d.move_page(first + k, to + k)?; + } + } else { + for k in 0..n { + d.move_page(first + k, to + k)?; + } + } + Ok(json!({"page": to})) + }) + } + ), + cmd!( + "layout.pages.applyParent", + "Apply Parent to Pages…", + ["Layout", "Pages"], + None, + "{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}", + has_doc, + |s, p| { + let pages: Vec = p + .get("pages") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) + .unwrap_or_default(); + let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None); + s.edit(|d, _| { + d.apply_parent(&pages, parent)?; + ok() + }) + } + ), + cmd!("layout.parents.new", "New Parent…", ["Layout", "Pages"], None, "{prefix?, name?: \"Parent\"}", has_doc, |s, p| { + let name = str_param(p, "name").unwrap_or("Parent").to_string(); + s.edit(|d, _| { + let prefix = str_param(p, "prefix").map(str::to_string).unwrap_or_else(|| d.next_parent_prefix()); + let src = d.page(0).cloned(); + let (columns, margins) = + src.as_ref().map_or_else(|| (designcraft_doc::Columns::default(), Margins::uniform(36.0)), |p| (p.columns.clone(), p.margins)); + let id = d.add_parent(&prefix, &name, columns.count, columns.gutter, margins); + // Dragged column guides carry over to the parent pages they still fit, mirrored + // on the parent page across the spine from the first page. + if let (Some(src), Some(sp)) = (&src, d.parents.iter_mut().find(|x| x.id == id)) { + for pg in &mut std::sync::Arc::make_mut(sp).pages { + let columns = pg.columns_from(src); + if columns.valid_positions(pg.margin_rect().width()).is_some() { + pg.columns.positions = columns.positions; + } + } + } + Ok(json!({"id": id.0, "prefix": prefix})) + }) + }), + cmd!( + "layout.parents.delete", + "Delete Parent", + [], + None, + "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items", + has_doc, + |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?; + s.edit(|d, sel| { + d.delete_parent(id)?; + *sel = Default::default(); + ok() + })?; + let st = s.doc_mut()?; + if st.doc.parents.is_empty() { + st.editing_parents = false; + } + ok() + } + ), + cmd!( + noundo "layout.parents.edit", + "Edit Parents", + [], + None, + "{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent", + has_doc, + |s, p| { + const ID: &str = "layout.parents.edit"; + let doc = s.doc()?.doc.clone(); + let parent = match p.get("parent") { + None | Some(Value::Null) => None, + v => match parent_ref(ID, &doc, v)? { + Some(Some(id)) => Some(id), + _ => return Err(bad(ID, "`parent` names a parent to show")), + }, + }; + let st = s.doc_mut()?; + let was = st.editing_parents; + let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was); + st.editing_parents = on; + st.selection = Default::default(); + st.bump_revision(); + // The parent to show: the one asked for, or the first when editing starts. + let index = match parent { + Some(id) => doc.parent_index(id), + None if !was && !doc.parents.is_empty() => Some(0), + None => None, + }; + let shown = index.filter(|_| on); + if let Some(i) = shown { + s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) }); + } + Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)})) + } + ), + cmd!( + "layout.parents.options", + "Parent Options…", + [], + None, + "{parent: id|\"B-Parent\"|\"B\", basedOn: id|\"A-Parent\"|\"A\"|null (= [None])} — the parent shows its base's items under its own, and so do the pages that use it → {parent, basedOn}", + has_doc, + parent_options + ), + cmd!( + "layout.marginsAndColumns", + "Margins and Columns…", + ["Layout"], + None, + "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins), parent? (a parent's prefix, label or index)} — document pages take these as their own margins; with `parent` the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own", + has_doc, + |s, p| { + let pages: Option> = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); + let parent = match p.get("parent") { + None | Some(Value::Null) => None, + Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?), + }; + let p = p.clone(); + let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let before = adjust.then(|| margin_boxes(d)); + if let Some(pi) = parent { + let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len()); + let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut); + if let Some(sp) = sp { + for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) { + if let Some(pg) = sp.pages.get_mut(qi) { + set_margins_columns(pg, &p); + } + } + } + d.sync_parent_margins(); + } else { + let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); + for abs in all { + let Some((si, pi)) = d.page_loc(abs) else { continue }; + let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue }; + set_margins_columns(pg, &p); + pg.local_margins = true; + } + } + if let Some(b) = before { + adjust_layout(d, &b); + } + ok() + }) + } + ), + cmd!( + "layout.moveColumnGuide", + "Move Column Guide", + [], + None, + "{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked", + column_guides_unlocked, + move_column_guide + ), + cmd!( + "layout.documentSetup", + "Document Setup…", + ["File"], + Some("Cmd+Alt+P"), + "{width?, height?, pages?: count, startPage?: n, facingPages?, binding?: leftToRight|rightToLeft, intent?: print|web|mobile, margins?: n | {top, bottom, inside, outside} (every page and parent page), bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size and margins)} → the document setup (margins: the first page's)", + has_doc, + |s, p| { + let p = p.clone(); + if p.as_object().is_none_or(|o| o.is_empty()) { + return Ok(document_setup(&s.doc()?.doc)); + } + let edges = |k: &str| -> Option<[f64; 4]> { + match p.get(k)? { + Value::Array(a) if a.len() == 4 => { + let v: Vec = a.iter().filter_map(Value::as_f64).map(|v| v.max(0.0)).collect(); + (v.len() == 4).then(|| [v[0], v[1], v[2], v[3]]) + } + v => v.as_f64().map(|b| [b.max(0.0); 4]), + } + }; + let (bleed, slug) = (edges("bleed"), edges("slug")); + for k in ["width", "height"] { + if let Some(v) = p.get(k) + && !v.as_f64().is_some_and(|x| x > 0.0 && x <= 15552.0) + { + return Err(bad("layout.documentSetup", format!("{k}: page size out of range (0 < size ≤ 216 in)"))); + } + } + let margins: Option = match p.get("margins") { + None => None, + Some(Value::Number(n)) => n.as_f64().map(Margins::uniform), + Some(v) => serde_json::from_value(v.clone()).ok(), + }; + if margins.is_some_and(|m| ![m.top, m.bottom, m.inside, m.outside].iter().all(|v| v.is_finite() && (0.0..=15552.0).contains(v))) { + return Err(bad("layout.documentSetup", "margins: 0 to 216 in")); + } + if p.get("margins").is_some() && margins.is_none() { + return Err(bad("layout.documentSetup", "margins: a number or {top, bottom, inside, outside}")); + } + let intent: Option = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()); + let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize); + let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + let before = adjust.then(|| margin_boxes(d)); + if let Some(f) = p.get("facingPages").and_then(Value::as_bool) { + d.settings.facing_pages = f; + } + if let Some(b) = p.get("binding").and_then(Value::as_str) { + d.settings.right_to_left_binding = b == "rightToLeft"; + d.order_parent_pages(); + } + if let Some(b) = bleed { + d.settings.bleed = b; + } + if let Some(b) = slug { + d.settings.slug = b; + } + if let Some(i) = intent { + d.settings.intent = i; + } + if let Some(n) = p.get("startPage").map(|v| start_number("layout.documentSetup", "startPage", v)).transpose()? { + if !d.sections.iter().any(|x| x.start == 0) { + d.sections.insert( + 0, + designcraft_doc::Section { + start: 0, + start_number: None, + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }, + ); + } + if let Some(first) = d.sections.iter_mut().find(|x| x.start == 0) { + first.start_number = Some(n); + } + } + // Number of Pages: add at the end (with the last page's parent) or remove from the end. + if let Some(n) = pages { + let have = d.page_count(); + if n > have { + let parent = d.page_loc(have - 1).and_then(|(si, pi)| d.spreads[si].pages[pi].parent); + d.insert_pages(Some(have - 1), n - have, parent)?; + } else if n < have { + d.delete_pages(&(n..have).collect::>())?; + } + } + let w = p.get("width").and_then(Value::as_f64); + let h = p.get("height").and_then(Value::as_f64); + // Pages with a liquid rule re-flow their objects (Adjust Layout aside). + let liquid_before: Vec<(usize, usize, designcraft_geom::Rect)> = if adjust { + vec![] + } else { + d.spreads + .iter() + .enumerate() + .flat_map(|(si, sp)| { + sp.pages.iter().enumerate().filter(|(_, pg)| !pg.liquid.is_off()).map(move |(pi, pg)| (si, pi, pg.bounds())) + }) + .collect() + }; + if w.is_some() || h.is_some() { + d.settings.page_width = w.unwrap_or(d.settings.page_width); + d.settings.page_height = h.unwrap_or(d.settings.page_height); + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + let sp = std::sync::Arc::make_mut(sp); + for pg in &mut sp.pages { + set_page_width(pg, d.settings.page_width); + pg.height = d.settings.page_height; + } + sp.relayout(); + } + } + if let Some(m) = margins { + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + for pg in &mut std::sync::Arc::make_mut(sp).pages { + pg.margins = m; + } + } + // Pages that now match their parent page follow its later changes; local + // columns stay local. + d.unmark_matching_local_margins(); + d.sync_parent_margins(); + } + d.repaginate(); + if let Some(b) = before { + adjust_layout(d, &b); + } + for (si, pi, old) in liquid_before { + crate::cmd::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, old); + } + Ok(document_setup(d)) + }) + } + ), + cmd!( + "layout.pages.toSpread", + "Add Page to Spread", + ["Layout", "Pages"], + None, + "{page (1-based), spread (0-based)} — the page joins that spread (up to 10 pages; the spread stops shuffling)", + has_doc, + |s, p| { + let page = + p.get("page").and_then(Value::as_u64).filter(|n| *n >= 1).ok_or_else(|| bad("layout.pages.toSpread", "missing page"))? as usize; + let spread = p.get("spread").and_then(Value::as_u64).ok_or_else(|| bad("layout.pages.toSpread", "missing spread"))? as usize; + s.edit(|d, _| { + d.page_to_spread(page - 1, spread)?; + Ok(json!({"spreads": d.spreads.len()})) + }) + } + ), + cmd!( + "layout.spreadShuffle", + "Allow Selected Spread to Shuffle", + ["Layout", "Pages"], + None, + "{spread (0-based), allow: bool} — off keeps the spread's pages together when pages are added or removed", + has_doc, + |s, p| { + let si = p.get("spread").and_then(Value::as_u64).unwrap_or(0) as usize; + let allow = p.get("allow").and_then(Value::as_bool).unwrap_or(true); + s.edit(|d, _| { + let sp = d.spreads.get_mut(si).ok_or_else(|| bad("layout.spreadShuffle", "no such spread"))?; + std::sync::Arc::make_mut(sp).allow_shuffle = allow; + if allow { + d.repaginate(); + } + ok() + }) + } + ), + cmd!( + "layout.pageSize", + "Page Size", + [], + None, + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", + has_doc, + page_size + ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let ids: Vec = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = designcraft_doc::Selection::items(ids); + } + st.selection.page = page; + st.bump_revision(); + Ok(json!({"page": page.map(|p| p + 1)})) + } + ), + cmd!( + "layout.section", + "Numbering & Section Options…", + ["Layout"], + None, + "{page (1-based; starts a section there), startNumber?: n (1–99999)|null (continue), style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits|kanji, prefix? (up to 8 characters; no + or comma), includePrefix?, marker?, remove?: bool}", + has_doc, + |s, p| { + let edit = SectionEdit::parse("layout.section", s, p)?; + s.edit(|d, _| { + edit.apply(d); + Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>()})) + }) + } + ), + cmd!( + "layout.chapterNumbering", + "Document Chapter Numbering", + [], + None, + "{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|arabicLeadingZero|arabicThreeDigits|arabicFourDigits, start?: n (Start Chapter Numbering at), source?: automatic|userDefined|sameAsPrevious} → {chapterNumber, label, style, source, book: the open book holding the document, or null}; no parameters reports the setting", + has_doc, + |s, p| { + if p.as_object().is_none_or(|o| o.is_empty()) { + return chapter_numbering(s); + } + let edit = ChapterEdit::parse("layout.chapterNumbering", p)?; + s.edit(|d, _| { + edit.apply(d); + ok() + })?; + chapter_numbering(s) + } + ), + cmd!( + "layout.numberingOptions", + "Apply Numbering & Section Options", + [], + None, + "{page, …the settings as layout.section, chapter?: {…the settings as layout.chapterNumbering}} — the section and the document's chapter numbering in one undo step; if either is refused nothing changes", + has_doc, + |s, p| { + let section = SectionEdit::parse("layout.numberingOptions", s, p)?; + let chapter = match p.get("chapter") { + Some(c) => Some(ChapterEdit::parse("layout.numberingOptions", c)?), + None => None, + }; + s.edit(|d, _| { + section.apply(d); + if let Some(chapter) = &chapter { + chapter.apply(d); + } + Ok(json!({"names": (0..d.page_count()).map(|i| d.page_name(i)).collect::>(), "chapter": d.chapter_label()})) + }) + } + ), + cmd!("layer.move", "Move Layer", [], None, "{id, to: index (0 = top/frontmost)}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing id"))?); + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad("layer.move", "missing to"))?; + s.edit(|d, _| { + let i = d.layers.iter().position(|l| l.id == id).ok_or_else(|| bad("layer.move", "no such layer"))?; + let to = usize::try_from(to).ok().filter(|t| *t < d.layers.len()).ok_or_else(|| bad("layer.move", "to out of range"))?; + let l = d.layers.remove(i); + d.layers.insert(to, l); + ok() + }) + }), + cmd!(noundo "layer.selectItems", "Select All on Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let st = s.doc_mut()?; + let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); + st.selection = designcraft_doc::Selection::items(ids); + st.bump_revision(); + ok() + }), + cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { + let name = str_param(p, "name").map(str::to_string); + let r = s.edit(|d, _| { + let id = LayerId(d.alloc()); + let n = d.layers.len(); + let name = name.clone().unwrap_or_else(|| format!("Layer {}", n + 1)); + d.layers.insert( + 0, + Layer { + id, + name, + color: LAYER_COLORS[n % LAYER_COLORS.len()].1, + visible: true, + locked: false, + printable: true, + show_guides: true, + suppress_wrap_when_hidden: false, + }, + ); + Ok(id) + })?; + s.doc_mut()?.active_layer = r; + Ok(json!({"id": r.0})) + }), + cmd!( + "layer.set", + "Layer Options…", + [], + None, + "{id, name?, color?: [r,g,b] | layer colour name (\"Light Blue\", \"Red\", …), visible?, locked?, printable?, showGuides?, suppressWrapWhenHidden?} — names are unique and not empty", + has_doc, + set_layer + ), + cmd!("layer.delete", "Delete Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + s.edit(|d, sel| { + if d.layers.len() < 2 { + return Err(bad("layer.delete", "a document needs at least one layer")); + } + let doomed: Vec<_> = + d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().filter(|i| i.layer == id).map(|i| i.id)).collect(); + for i in doomed { + let _ = d.remove_item(i); + } + d.layers.retain(|l| l.id != id); + *sel = Default::default(); + ok() + }) + }), + cmd!( + "layer.merge", + "Merge Layers", + [], + None, + "{ids: [layer ids], into?: layer id (default: the first)} — their objects move to `into`, the other layers are deleted", + has_doc, + |s, p| { + let ids: Vec = + p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(LayerId).collect()).unwrap_or_default(); + let into = p + .get("into") + .and_then(Value::as_u64) + .map(LayerId) + .or_else(|| ids.first().copied()) + .ok_or_else(|| bad("layer.merge", "no layers"))?; + s.edit(|d, _| { + if d.layer(into).is_none() || ids.iter().any(|l| d.layer(*l).is_none()) { + return Err(bad("layer.merge", "no such layer")); + } + let gone: Vec = ids.iter().copied().filter(|l| *l != into).collect(); + for sp in d.spreads.iter_mut().chain(d.parents.iter_mut()) { + if !sp.items.iter().any(|i| gone.contains(&i.layer)) { + continue; + } + for it in &mut std::sync::Arc::make_mut(sp).items { + if gone.contains(&it.layer) { + std::sync::Arc::make_mut(it).layer = into; + } + } + } + d.layers.retain(|l| !gone.contains(&l.id)); + Ok(json!({"merged": gone.len()})) + }) + } + ), + cmd!("layer.deleteUnused", "Delete Unused Layers", [], None, "{} — layers without objects (one layer always stays)", has_doc, |s, _| { + s.edit(|d, _| { + let used: std::collections::HashSet = + d.spreads.iter().chain(d.parents.iter()).flat_map(|sp| sp.items.iter().map(|i| i.layer)).collect(); + let before = d.layers.len(); + if used.is_empty() { + // Nothing anywhere: keep the first layer. + d.layers.truncate(1); + } else { + d.layers.retain(|l| used.contains(&l.id)); + } + Ok(json!({"deleted": before - d.layers.len()})) + }) + }), + cmd!( + "layer.others", + "Hide/Lock Others", + [], + None, + "{id, hide?: bool, lock?: bool, show?: true (Show All Layers), unlock?: true (Unlock All Layers)} — Hide Others / Lock Others act on every layer but `id`", + has_doc, + |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); + let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock")); + s.edit(|d, _| { + for l in &mut d.layers { + if show { + l.visible = true; + } + if unlock { + l.locked = false; + } + if l.id != id { + if hide { + l.visible = false; + } + if lock { + l.locked = true; + } + } + } + ok() + }) + } + ), + cmd!(noundo "layer.activate", "Set Active Layer", [], None, "{id}", has_doc, |s, p| { + let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0)); + let st = s.doc_mut()?; + if st.doc.layer(id).is_none() { + return Err(bad("layer.activate", "no such layer")); + } + st.active_layer = id; + st.bump_revision(); + ok() + }), + ] +} diff --git a/crates/engine/src/cmd/layout/sections.rs b/crates/engine/src/cmd/layout/sections.rs new file mode 100644 index 000000000..3a388743d --- /dev/null +++ b/crates/engine/src/cmd/layout/sections.rs @@ -0,0 +1,251 @@ +use super::{Result, Session, Value, bad, json}; + +/// A start page number parameter: a whole number in 1..=[`designcraft_doc::MAX_PAGE_NUMBER`]. +pub(super) fn start_number(cmd: &str, key: &str, v: &Value) -> Result { + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .filter(|n| (1..=designcraft_doc::MAX_PAGE_NUMBER).contains(n)) + .ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number from 1 to {}", designcraft_doc::MAX_PAGE_NUMBER))) +} + +/// The largest chapter number (as for page numbers). +const MAX_CHAPTER: u64 = designcraft_doc::MAX_PAGE_NUMBER as u64; + +/// A `layout.section` edit, checked before the document changes. +pub(super) struct SectionEdit { + /// Absolute index of the section's first page. + start: usize, + remove: bool, + /// Some(None): continue from the previous section. + start_number: Option>, + style: Option, + prefix: Option, + include_prefix: Option, + marker: Option, +} + +impl SectionEdit { + pub(super) fn parse(cmd: &str, s: &Session, p: &Value) -> Result { + let page = p.get("page").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing page"))?; + let n = s.doc()?.doc.page_count() as u64; + if page == 0 || page > n { + return Err(bad(cmd, format!("no page {page}"))); + } + let prefix = p.get("prefix").and_then(Value::as_str).map(str::to_string); + if prefix.as_ref().is_some_and(|x| x.chars().count() > 8 || x.contains(['+', ','])) { + return Err(bad(cmd, "section prefix: up to 8 characters, without + or ,")); + } + let start_number = match p.get("startNumber") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => Some(Some(start_number(cmd, "startNumber", v)?)), + }; + let style = match p.get("style") { + None => None, + Some(v) => match serde_json::from_value(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) => return Err(bad(cmd, "symbols don't number pages")), + Ok(style) => Some(style), + Err(e) => return Err(bad(cmd, e.to_string())), + }, + }; + Ok(SectionEdit { + start: (page - 1) as usize, + remove: p.get("remove").and_then(Value::as_bool).unwrap_or(false), + start_number, + style, + prefix, + include_prefix: p.get("includePrefix").and_then(Value::as_bool), + marker: p.get("marker").and_then(Value::as_str).map(str::to_string), + }) + } + + pub(super) fn apply(&self, d: &mut designcraft_doc::Document) { + let start = self.start; + // Page 1 always starts a section. + if self.remove && start != 0 { + d.sections.retain(|x| x.start != start); + // The pages after it follow the previous section's numbers again. + d.repaginate(); + return; + } + let mut sec = d.sections.iter().find(|x| x.start == start).cloned().unwrap_or(designcraft_doc::Section { + start, + start_number: None, + style: Default::default(), + prefix: String::new(), + marker: String::new(), + include_prefix: false, + }); + if let Some(n) = self.start_number { + sec.start_number = n; + } + if let Some(style) = self.style { + sec.style = style; + } + if let Some(prefix) = &self.prefix { + sec.prefix = prefix.clone(); + } + if let Some(on) = self.include_prefix { + sec.include_prefix = on; + } + if let Some(marker) = &self.marker { + sec.marker = marker.clone(); + } + d.sections.retain(|x| x.start != start); + d.sections.push(sec); + d.sections.sort_by_key(|x| x.start); + // Facing pages take their sides from their numbers: odd on the right. + d.repaginate(); + } +} + +/// A `layout.chapterNumbering` edit, checked before the document changes. +pub(super) struct ChapterEdit { + style: Option, + source: Option, + start: Option, +} + +impl ChapterEdit { + pub(super) fn parse(cmd: &str, p: &Value) -> Result { + let style = match p.get("style") { + Some(v) => match serde_json::from_value::(v.clone()) { + Ok(designcraft_doc::NumberStyle::Symbols) | Err(_) => return Err(bad(cmd, format!("unknown chapter style {v}"))), + Ok(style) => Some(style), + }, + None => None, + }; + let mut source = match p.get("source") { + Some(v) => Some( + serde_json::from_value::(v.clone()) + .map_err(|_| bad(cmd, format!("unknown chapter number source {v}")))?, + ), + None => None, + }; + let start = match p.get("start") { + Some(v) => { + Some(v.as_u64().filter(|n| (1..=MAX_CHAPTER).contains(n)).ok_or_else(|| bad(cmd, format!("chapter start: 1 to {MAX_CHAPTER}")))? + as u32) + } + None => None, + }; + // A start number alone means Start Chapter Numbering at. + if start.is_some() && source.is_none() { + source = Some(designcraft_doc::ChapterSource::UserDefined); + } + Ok(ChapterEdit { style, source, start }) + } + + pub(super) fn apply(&self, d: &mut designcraft_doc::Document) { + if let Some(style) = self.style { + d.settings.chapter_style = style; + } + if let Some(source) = self.source { + d.settings.chapter_source = source; + } + if let Some(n) = self.start { + d.settings.chapter_number = n; + } + } +} + +/// The active document's chapter numbering, and the open book that holds it (what +/// `layout.chapterNumbering {}` reports, read without running a command). +pub fn chapter_numbering(s: &Session) -> Result { + let st = s.doc()?; + let set = &st.doc.settings; + let book = s + .book + .as_ref() + .filter(|b| st.path.as_deref().is_some_and(|p| b.documents.iter().any(|d| std::path::Path::new(d) == std::path::Path::new(p)))) + .map(|b| std::path::Path::new(&b.path).file_stem().map_or_else(|| b.path.clone(), |n| n.to_string_lossy().to_string())); + Ok( + json!({"chapterNumber": set.chapter_number.max(1), "label": st.doc.chapter_label(), "style": set.chapter_style, "source": set.chapter_source, "book": book}), + ) +} + +#[cfg(test)] +mod section_side_tests { + use serde_json::json; + + use crate::Session; + + /// Each spread's pages as (name, side), side `L`, `R` or `S`. + fn spreads(s: &Session) -> Vec> { + let d = &s.doc().unwrap().doc; + let mut abs = 0; + d.spreads + .iter() + .map(|sp| { + sp.pages + .iter() + .map(|p| { + let side = match p.side { + designcraft_doc::PageSide::Left => 'L', + designcraft_doc::PageSide::Right => 'R', + designcraft_doc::PageSide::Single => 'S', + }; + abs += 1; + (d.page_name(abs - 1), side) + }) + .collect() + }) + .collect() + } + + fn pages(v: &[&[(&str, char)]]) -> Vec> { + v.iter().map(|sp| sp.iter().map(|(n, c)| (n.to_string(), *c)).collect()).collect() + } + + #[test] + fn a_section_starting_at_one_on_a_left_page_moves_it_to_the_right() { + // Chris Nielsen's "Numbering Page Sections" (7:03): with the spread 8–9 selected, the + // section starts on page 8, which becomes a lone right-hand page 1; the pages after it + // shuffle and their objects go with them. Undo puts them back. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": true, "pages": 10})).unwrap(); + let id = s.execute("frame.create", &json!({"spread": 4, "rect": [700, 100, 800, 200], "content": "text", "text": "TOPIC"})).unwrap()["id"] + .as_u64() + .unwrap(); + let page_of = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.page_of_item(designcraft_doc::ItemId(id)).unwrap() + }; + assert_eq!(page_of(&s), 8, "page 9, the right page of the spread 8–9"); + let before = spreads(&s); + s.execute("layout.section", &json!({"page": 8, "startNumber": 1})).unwrap(); + assert_eq!( + spreads(&s), + pages(&[ + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + &[("4", 'L'), ("5", 'R')], + &[("6", 'L'), ("7", 'R')], + &[("1", 'R')], + &[("2", 'L'), ("3", 'R')], + ]) + ); + assert_eq!(page_of(&s), 8, "the object stays on its page, now the left page of a spread"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spreads(&s), before); + // On a right page (the video's page 9) nothing moves; an even start on a right page moves + // it to the left. + s.execute("layout.section", &json!({"page": 9, "startNumber": 1})).unwrap(); + assert_eq!(spreads(&s)[4], pages(&[&[("8", 'L'), ("1", 'R')]])[0]); + s.execute("layout.section", &json!({"page": 9, "startNumber": 2})).unwrap(); + assert_eq!(spreads(&s)[4..], pages(&[&[("8", 'L')], &[("2", 'L'), ("3", 'R')]])); + // Removing the section numbers the pages on from 9 again. + s.execute("layout.section", &json!({"page": 9, "remove": true})).unwrap(); + assert_eq!(spreads(&s), before); + } + + #[test] + fn single_sided_documents_keep_their_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"facingPages": false, "pages": 4})).unwrap(); + s.execute("layout.section", &json!({"page": 2, "startNumber": 1})).unwrap(); + let sp = spreads(&s); + assert_eq!(sp.len(), 4); + assert!(sp.iter().all(|p| p.len() == 1 && p[0].1 == 'S'), "{sp:?}"); + } +} diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index c39e05668..3fc67f822 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -396,8 +396,23 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi #[cfg(not(target_arch = "wasm32"))] fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> { + reload_capped(path, super::interchange::MAX_LINKED_FILE_BYTES) +} + +/// A linked graphic read again from disk, and its pixel size, reading at most `cap` bytes. +/// +/// How large a linked file is belongs to whoever wrote the document, not to whoever opens it: the +/// path comes out of the document, and Update, Relink and Relink Folder all follow it. An +/// unbounded `read` of a link pointing at a multi-GB file exhausted memory (#1596). The cap is +/// the one packaging already applies to a linked file, and a file over it is refused by name +/// rather than read. +#[cfg(not(target_arch = "wasm32"))] +fn reload_capped(path: impl AsRef, cap: u64) -> Result<(Vec, (u32, u32))> { let path = path.as_ref(); - let b = crate::io::read_file(path).map_err(EngineError::Other)?; + let b = crate::io::read_capped(path, cap).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?; + if b.len() as u64 > cap { + return Err(bad("links", format!("{}: the linked file is larger than {} MB", path.display(), cap / (1024 * 1024)))); + } if designcraft_render::is_pdf(&b) { designcraft_render::check_pdf_input_size(b.len()).map_err(|e| bad("links", format!("{}: {e}", path.display())))?; } @@ -577,358 +592,7 @@ fn update(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - fn png(w: u32, h: u32) -> Vec { - designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() - } - - fn pdf(sizes: &[(u32, u32)]) -> Vec { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap(); - for (i, (width, height)) in sizes.iter().enumerate() { - s.execute("layout.pageSize", &json!({"pages": [i + 1], "width": width, "height": height})).unwrap(); - } - let result = s.execute("file.exportPdf", &json!({})).unwrap(); - super::super::file::base64_decode(result["base64"].as_str().unwrap()) - } - - #[test] - fn anchored_image_lists_its_page_and_resolution() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); - let r = s - .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})) - .unwrap(); - let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let placed = s - .execute( - "file.place", - &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}), - ) - .unwrap(); - let id = placed["id"].as_u64().unwrap(); - let before = s.execute("links.list", &json!({})).unwrap(); - assert_eq!(before[0]["uses"][0]["ppi"], 20.0); - s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap(); - let list = s.execute("links.list", &json!({})).unwrap(); - let uses = list[0]["uses"].as_array().unwrap(); - assert_eq!(uses.len(), 1, "anchored image must have one use: {list}"); - assert_eq!(uses[0]["page"], "2"); - assert_eq!(uses[0]["ppi"], 20.0); - } - - #[test] - fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() { - use std::io::Read; - let bytes = super::super::file::eps_place_tests::tiff_eps(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let placed = s.execute("file.place", &json!({"base64":super::super::file::base64_encode(&bytes),"name":"logo.eps"})).unwrap(); - let aid = AssetId(placed["asset"].as_u64().unwrap()); - let before = s.doc().unwrap().doc.clone(); - let html = s.execute("file.exportHtml", &json!({})).unwrap(); - let encoded = html["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); - let png = super::super::file::base64_decode(encoded); - assert!(png.starts_with(b"\x89PNG")); - assert_eq!(designcraft_render::image_size(&png), Some((2, 2))); - assert_eq!(designcraft_render::decode_pixmap(&png).unwrap().data()[0], designcraft_render::decode_pixmap(&bytes).unwrap().data()[0]); - let result = s.execute("file.exportEpub", &json!({})).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(result["base64"].as_str().unwrap()))).unwrap(); - let mut image = Vec::new(); - z.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); - assert_eq!(image, png); - let mut opf = String::new(); - z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - assert!(opf.contains("media-type=\"image/png\"")); - assert!(!opf.contains("image/tiff")); - assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); - s.edit(|d, _| { - Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EOF\n".to_vec()); - Ok(()) - }) - .unwrap(); - let before = s.doc().unwrap().doc.clone(); - for command in ["file.exportHtml", "file.exportEpub"] { - let result = s.execute(command, &json!({})).unwrap(); - assert_eq!(result["warnings"].as_array().unwrap().len(), 1, "{command}"); - if command == "file.exportHtml" { - assert!(!result["text"].as_str().unwrap().contains("("Subtype").is_some_and(|n| n.as_str() == "Image")) - .count(), - 1 - ); - let result = s.execute("file.exportHtml", &json!({})).unwrap(); - let encoded = result["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); - assert_eq!(super::super::file::base64_decode(encoded), proxy); - let result = s.execute("file.exportEpub", &json!({})).unwrap(); - let bytes = super::super::file::base64_decode(result["base64"].as_str().unwrap()); - let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut image = Vec::new(); - archive.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); - assert_eq!(image, proxy); - let mut opf = String::new(); - archive.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - assert!(opf.contains("media-type=\"image/png\"")); - assert!(!opf.contains("application/postscript")); - assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "exports must leave the source document intact"); - assert_eq!(before.assets[&aid].data.as_slice(), source); - } - - #[test] - fn eps_relink_update_and_folder_relink_retain_source() { - let dir = std::env::temp_dir().join(format!("dc-links-eps-{}", std::process::id())); - std::fs::create_dir_all(dir.join("new")).unwrap(); - let eps = |w: u32| format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 {w} 10\n%%EOF\n"); - let original = dir.join("original.eps"); - let replacement = dir.join("replacement.eps"); - std::fs::write(&original, eps(10)).unwrap(); - std::fs::write(&replacement, eps(30)).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap(); - let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); - std::fs::write(&original, eps(20)).unwrap(); - assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); - assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((40, 20))); - s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); - let a = &s.doc().unwrap().doc.assets[&aid]; - assert_eq!(a.pixels, Some((60, 20))); - assert_eq!(a.mime, "application/postscript"); - assert_eq!(a.data.as_slice(), eps(30).as_bytes()); - let moved = dir.join("new/replacement.eps"); - std::fs::rename(&replacement, &moved).unwrap(); - assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1); - assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str()); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pdf_relink_and_update_preserve_selected_page_size() { - let dir = std::env::temp_dir().join(format!("dc-links-pdf-size-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("original.pdf"); - let replacement = dir.join("replacement.pdf"); - std::fs::write(&original, pdf(&[(10, 10), (20, 30)])).unwrap(); - std::fs::write(&replacement, pdf(&[(40, 40), (50, 60)])).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); - let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); - s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); - let a = &s.doc().unwrap().doc.assets[&aid]; - assert_eq!(a.page, 1); - assert_eq!(a.pixels, Some((50, 60))); - assert!(designcraft_render::render_pdf_page(&a.data, a.page as usize, 64).is_some()); - std::fs::write(&replacement, pdf(&[(70, 70), (80, 90)])).unwrap(); - assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); - assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((80, 90))); - let raster = dir.join("replacement.png"); - std::fs::write(&raster, png(4, 5)).unwrap(); - s.execute("links.relink", &json!({"asset": aid.0, "path": raster.to_string_lossy()})).unwrap(); - assert_eq!(s.doc().unwrap().doc.assets[&aid].page, 0); - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn pdf_reload_rejects_missing_page_without_edit() { - let full = pdf(&[(10, 10), (20, 30)]); - let short = pdf(&[(10, 10)]); - let dir = std::env::temp_dir().join(format!("dc-links-pdf-page-{}", std::process::id())); - std::fs::create_dir_all(dir.join("new")).unwrap(); - let original = dir.join("book.pdf"); - let replacement = dir.join("new/book.pdf"); - std::fs::write(&replacement, short.clone()).unwrap(); - for command in ["links.relink", "links.relinkFolder", "links.update"] { - std::fs::write(&original, &full).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); - let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); - if command == "links.update" { - std::fs::write(&original, &short).unwrap(); - } - let st = s.doc().unwrap(); - let before = st.doc.clone(); - let revision = st.revision; - let undo = st.history.undo.len(); - let journal = s.journal.len(); - let params = match command { - "links.relinkFolder" => json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}), - "links.update" => json!({"asset": aid.0}), - _ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}), - }; - let err = s.execute(command, ¶ms).unwrap_err(); - assert!(err.to_string().contains("PDF page 2"), "{command}: {err}"); - let st = s.doc().unwrap(); - assert!(Arc::ptr_eq(&st.doc, &before), "{command}"); - assert_eq!(st.revision, revision); - assert_eq!(st.history.undo.len(), undo); - assert_eq!(s.journal.len(), journal); - assert!(designcraft_render::render_pdf_page(&st.doc.assets[&aid].data, 1, 64).is_some()); - } - let _ = std::fs::remove_dir_all(dir); - } - - #[test] - fn link_status_relink_update_embed() { - let dir = std::env::temp_dir().join(format!("dc-links-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let a = dir.join("a.png"); - let b = dir.join("b.png"); - std::fs::write(&a, png(10, 10)).unwrap(); - std::fs::write(&b, png(20, 10)).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": a.to_string_lossy(), "x": 72, "y": 72, "width": 72})).unwrap(); - let l = s.execute("links.list", &json!({})).unwrap(); - assert_eq!(l[0]["status"], "ok"); - assert_eq!(l[0]["uses"][0]["ppi"], 10.0, "10 px over 1 inch"); - let aid = l[0]["asset"].as_u64().unwrap(); - // The file changes on disk. - std::fs::write(&a, png(30, 10)).unwrap(); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified"); - s.execute("links.update", &json!({})).unwrap(); - let l = s.execute("links.list", &json!({})).unwrap(); - assert_eq!(l[0]["status"], "ok"); - assert_eq!(l[0]["pixels"], json!([30, 10])); - // Missing, then relinked. - std::fs::remove_file(&a).unwrap(); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing"); - s.execute("links.relink", &json!({"asset": aid, "path": b.to_string_lossy()})).unwrap(); - let l = s.execute("links.list", &json!({})).unwrap(); - assert_eq!((l[0]["status"].as_str(), l[0]["name"].as_str()), (Some("ok"), Some("b.png"))); - // Go to selects the frame; embed forgets the file. - let r = s.execute("links.goTo", &json!({"asset": aid})).unwrap(); - assert_eq!(r["ids"].as_array().unwrap().len(), 1); - s.execute("links.embed", &json!({"asset": aid})).unwrap(); - assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded"); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod tests; #[cfg(test)] mod relink_folder_tests { @@ -1128,6 +792,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: sid, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1173,6 +838,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1209,6 +875,7 @@ mod shared_asset_tests { designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)), ); it.content = Content::Graphic(Graphic { + layer_options: None, asset: missing, size: (4.0, 4.0), xf: designcraft_geom::Affine::IDENTITY, @@ -1267,6 +934,47 @@ mod text_link_tests { st.paras.iter().map(|p| p.style.clone()).collect() } + fn assert_link_update_replaces_and_warns(s: &mut Session, path: &std::path::Path, sid: StoryId, a: u64, b: u64) { + // The file changes (with tracked changes): Modified; Update imports it as if accepted, with + // the saved style map, into the same frames, without new renamed styles. + std::fs::write( + path, + docx(r#"Second revised old draft."#), + ) + .unwrap(); + assert_eq!(text_rows(s)[0]["status"], "modified"); + let r = s.execute("links.update", &json!({"story": sid.0})).unwrap(); + assert_eq!(r["updated"], 1); + assert_eq!(r["warnings"], json!([]), "no edits in the layout to replace"); + let updated = story(s, sid); + assert_eq!(updated.text, "Head\nSecond revised draft."); + assert_eq!(styles(&updated), ["Headline", "Body_wrd_1"]); + assert_eq!(updated.frames, [ItemId(a), ItemId(b)], "frames and thread kept"); + assert!(s.doc().unwrap().doc.styles.para("Body_wrd_2").is_none()); + assert!(s.doc().unwrap().doc.styles.para("Title").is_none(), "mapped styles aren't imported"); + assert_eq!(text_rows(s)[0]["status"], "ok"); + + // An edit in the layout, then the file changes again: Update replaces the edit and warns. + s.execute("story.setText", &json!({"story": sid.0, "text": "Fixed in layout"})).unwrap(); + assert_eq!(text_rows(s)[0]["edited"], true); + std::fs::write(path, docx("Third draft.")).unwrap(); + let r = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(r["updated"], 1); + let w = r["warnings"][0].as_str().unwrap(); + assert!(w.contains("chapter.docx") && w.contains("replaced"), "{w}"); + assert_eq!(story(s, sid).text, "Head\nThird draft."); + assert_eq!(text_rows(s)[0]["edited"], false); + // Undo brings the edit back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(s, sid).text, "Fixed in layout"); + s.execute("edit.redo", &json!({})).unwrap(); + + // Saved and opened again: the link and its options are kept. + let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); + assert_eq!(back.story(sid).unwrap().file_link, story(s, sid).file_link); + assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default(), None), "ok"); + } + #[test] fn linked_word_file_status_update_relink_and_unlink() { let dir = std::env::temp_dir().join(format!("dc-text-link-{}", std::process::id())); @@ -1306,44 +1014,7 @@ mod text_link_tests { assert_eq!((rows[0]["status"].as_str(), rows[0]["edited"].as_bool()), (Some("ok"), Some(false))); assert_eq!(rows[0]["uses"].as_array().unwrap().len(), 2, "both threaded frames"); - // The file changes (with tracked changes): Modified; Update imports it as if accepted, with - // the saved style map, into the same frames, without new renamed styles. - std::fs::write( - &path, - docx(r#"Second revised old draft."#), - ) - .unwrap(); - assert_eq!(text_rows(&mut s)[0]["status"], "modified"); - let r = s.execute("links.update", &json!({"story": sid.0})).unwrap(); - assert_eq!(r["updated"], 1); - assert_eq!(r["warnings"], json!([]), "no edits in the layout to replace"); - let updated = story(&s, sid); - assert_eq!(updated.text, "Head\nSecond revised draft."); - assert_eq!(styles(&updated), ["Headline", "Body_wrd_1"]); - assert_eq!(updated.frames, [ItemId(a), ItemId(b)], "frames and thread kept"); - assert!(s.doc().unwrap().doc.styles.para("Body_wrd_2").is_none()); - assert!(s.doc().unwrap().doc.styles.para("Title").is_none(), "mapped styles aren't imported"); - assert_eq!(text_rows(&mut s)[0]["status"], "ok"); - - // An edit in the layout, then the file changes again: Update replaces the edit and warns. - s.execute("story.setText", &json!({"story": sid.0, "text": "Fixed in layout"})).unwrap(); - assert_eq!(text_rows(&mut s)[0]["edited"], true); - std::fs::write(&path, docx("Third draft.")).unwrap(); - let r = s.execute("links.update", &json!({})).unwrap(); - assert_eq!(r["updated"], 1); - let w = r["warnings"][0].as_str().unwrap(); - assert!(w.contains("chapter.docx") && w.contains("replaced"), "{w}"); - assert_eq!(story(&s, sid).text, "Head\nThird draft."); - assert_eq!(text_rows(&mut s)[0]["edited"], false); - // Undo brings the edit back. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(story(&s, sid).text, "Fixed in layout"); - s.execute("edit.redo", &json!({})).unwrap(); - - // Saved and opened again: the link and its options are kept. - let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap(); - assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link); - assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default(), None), "ok"); + assert_link_update_replaces_and_warns(&mut s, &path, sid, a, b); // The file moves: Missing; Relink to it imports it into the story. let moved = dir.join("moved").join("chapter.docx"); diff --git a/crates/engine/src/cmd/links/tests.rs b/crates/engine/src/cmd/links/tests.rs new file mode 100644 index 000000000..8089b24ce --- /dev/null +++ b/crates/engine/src/cmd/links/tests.rs @@ -0,0 +1,375 @@ +use super::*; + +fn png(w: u32, h: u32) -> Vec { + designcraft_render::Rendered { width: w, height: h, pixels: vec![200u8; (w * h * 4) as usize] }.to_png() +} + +/// A linked file's size is the document's choice, so a link is read under a cap (#1596). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_linked_file_over_the_cap_is_refused_instead_of_read() { + let dir = std::env::temp_dir().join(format!("dc-link-cap-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("big.png"); + let bytes = png(40, 40); + std::fs::write(&path, &bytes).unwrap(); + + // Under the cap the file is read and measured as before. + let (read, px) = reload_capped(&path, bytes.len() as u64).expect("a file within the cap is read"); + assert_eq!((read.len(), px), (bytes.len(), (40, 40))); + + // Over it the file is refused by name, and the error says what the limit is. + let err = reload_capped(&path, (bytes.len() - 1) as u64).expect_err("a file over the cap is refused"); + let msg = err.to_string(); + assert!(msg.contains("big.png") && msg.contains("larger than"), "the error names the file and the limit: {msg}"); + + // The real cap is the one packaging uses, so an ordinary graphic still loads. + assert!(reload(&path).is_ok(), "a normal linked graphic is unaffected"); + let _ = std::fs::remove_dir_all(&dir); +} + +fn pdf(sizes: &[(u32, u32)]) -> Vec { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": sizes.len(), "facingPages": false})).unwrap(); + for (i, (width, height)) in sizes.iter().enumerate() { + s.execute("layout.pageSize", &json!({"pages": [i + 1], "width": width, "height": height})).unwrap(); + } + let result = s.execute("file.exportPdf", &json!({})).unwrap(); + super::super::file::base64_decode(result["base64"].as_str().unwrap()) +} + +#[test] +fn anchored_image_lists_its_page_and_resolution() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let placed = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}), + ) + .unwrap(); + let id = placed["id"].as_u64().unwrap(); + let before = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(before[0]["uses"][0]["ppi"], 20.0); + s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let uses = list[0]["uses"].as_array().unwrap(); + assert_eq!(uses.len(), 1, "anchored image must have one use: {list}"); + assert_eq!(uses[0]["page"], "2"); + assert_eq!(uses[0]["ppi"], 20.0); +} + +#[test] +fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() { + use std::io::Read; + let bytes = super::super::file::eps_place_tests::tiff_eps(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s.execute("file.place", &json!({"base64":super::super::file::base64_encode(&bytes),"name":"logo.eps"})).unwrap(); + let aid = AssetId(placed["asset"].as_u64().unwrap()); + let before = s.doc().unwrap().doc.clone(); + let html = s.execute("file.exportHtml", &json!({})).unwrap(); + let encoded = html["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); + let png = super::super::file::base64_decode(encoded); + assert!(png.starts_with(b"\x89PNG")); + assert_eq!(designcraft_render::image_size(&png), Some((2, 2))); + assert_eq!(designcraft_render::decode_pixmap(&png).unwrap().data()[0], designcraft_render::decode_pixmap(&bytes).unwrap().data()[0]); + let result = s.execute("file.exportEpub", &json!({})).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(super::super::file::base64_decode(result["base64"].as_str().unwrap()))).unwrap(); + let mut image = Vec::new(); + z.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); + assert_eq!(image, png); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("media-type=\"image/png\"")); + assert!(!opf.contains("image/tiff")); + assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc)); + s.edit(|d, _| { + Arc::make_mut(d.assets.get_mut(&aid).unwrap()).data = Arc::new(b"%!PS-Adobe-3.0 EPSF-3.0\n%%EOF\n".to_vec()); + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + for command in ["file.exportHtml", "file.exportEpub"] { + let result = s.execute(command, &json!({})).unwrap(); + assert_eq!(result["warnings"].as_array().unwrap().len(), 1, "{command}"); + if command == "file.exportHtml" { + assert!(!result["text"].as_str().unwrap().contains("("Subtype").is_some_and(|n| n.as_str() == "Image")) + .count(), + 1 + ); + let result = s.execute("file.exportHtml", &json!({})).unwrap(); + let encoded = result["text"].as_str().unwrap().split("data:image/png;base64,").nth(1).unwrap().split('"').next().unwrap(); + assert_eq!(super::super::file::base64_decode(encoded), proxy); + let result = s.execute("file.exportEpub", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(result["base64"].as_str().unwrap()); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut image = Vec::new(); + archive.by_name(&format!("OEBPS/images/logo-{}.png", aid.0)).unwrap().read_to_end(&mut image).unwrap(); + assert_eq!(image, proxy); + let mut opf = String::new(); + archive.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("media-type=\"image/png\"")); + assert!(!opf.contains("application/postscript")); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &before), "exports must leave the source document intact"); + assert_eq!(before.assets[&aid].data.as_slice(), source); +} + +#[test] +fn eps_relink_update_and_folder_relink_retain_source() { + let dir = std::env::temp_dir().join(format!("dc-links-eps-{}", std::process::id())); + std::fs::create_dir_all(dir.join("new")).unwrap(); + let eps = |w: u32| format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 {w} 10\n%%EOF\n"); + let original = dir.join("original.eps"); + let replacement = dir.join("replacement.eps"); + std::fs::write(&original, eps(10)).unwrap(); + std::fs::write(&replacement, eps(30)).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": original.to_string_lossy()})).unwrap(); + let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); + std::fs::write(&original, eps(20)).unwrap(); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((40, 20))); + s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); + let a = &s.doc().unwrap().doc.assets[&aid]; + assert_eq!(a.pixels, Some((60, 20))); + assert_eq!(a.mime, "application/postscript"); + assert_eq!(a.data.as_slice(), eps(30).as_bytes()); + let moved = dir.join("new").join("replacement.eps"); + std::fs::rename(&replacement, &moved).unwrap(); + assert_eq!(s.execute("links.relinkFolder", &json!({"dir": dir.join("new").to_string_lossy()})).unwrap()["relinked"], 1); + assert_eq!(s.doc().unwrap().doc.assets[&aid].link.as_deref(), moved.to_str()); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "ok"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pdf_relink_and_update_preserve_selected_page_size() { + let dir = std::env::temp_dir().join(format!("dc-links-pdf-size-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("original.pdf"); + let replacement = dir.join("replacement.pdf"); + std::fs::write(&original, pdf(&[(10, 10), (20, 30)])).unwrap(); + std::fs::write(&replacement, pdf(&[(40, 40), (50, 60)])).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); + let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); + s.execute("links.relink", &json!({"asset": aid.0, "path": replacement.to_string_lossy()})).unwrap(); + let a = &s.doc().unwrap().doc.assets[&aid]; + assert_eq!(a.page, 1); + assert_eq!(a.pixels, Some((50, 60))); + assert!(designcraft_render::render_pdf_page(&a.data, a.page as usize, 64).is_some()); + std::fs::write(&replacement, pdf(&[(70, 70), (80, 90)])).unwrap(); + assert_eq!(s.execute("links.update", &json!({})).unwrap()["updated"], 1); + assert_eq!(s.doc().unwrap().doc.assets[&aid].pixels, Some((80, 90))); + let raster = dir.join("replacement.png"); + std::fs::write(&raster, png(4, 5)).unwrap(); + s.execute("links.relink", &json!({"asset": aid.0, "path": raster.to_string_lossy()})).unwrap(); + assert_eq!(s.doc().unwrap().doc.assets[&aid].page, 0); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn pdf_reload_rejects_missing_page_without_edit() { + let full = pdf(&[(10, 10), (20, 30)]); + let short = pdf(&[(10, 10)]); + let dir = std::env::temp_dir().join(format!("dc-links-pdf-page-{}", std::process::id())); + std::fs::create_dir_all(dir.join("new")).unwrap(); + let original = dir.join("book.pdf"); + let replacement = dir.join("new/book.pdf"); + std::fs::write(&replacement, short.clone()).unwrap(); + for command in ["links.relink", "links.relinkFolder", "links.update"] { + std::fs::write(&original, &full).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": original.to_string_lossy(), "pdfPage": 2})).unwrap(); + let aid = s.doc().unwrap().doc.assets.keys().next().copied().unwrap(); + if command == "links.update" { + std::fs::write(&original, &short).unwrap(); + } + let st = s.doc().unwrap(); + let before = st.doc.clone(); + let revision = st.revision; + let undo = st.history.undo.len(); + let journal = s.journal.len(); + let params = match command { + "links.relinkFolder" => json!({"dir": dir.join("new").to_string_lossy(), "missingOnly": false}), + "links.update" => json!({"asset": aid.0}), + _ => json!({"asset": aid.0, "path": replacement.to_string_lossy()}), + }; + let err = s.execute(command, ¶ms).unwrap_err(); + assert!(err.to_string().contains("PDF page 2"), "{command}: {err}"); + let st = s.doc().unwrap(); + assert!(Arc::ptr_eq(&st.doc, &before), "{command}"); + assert_eq!(st.revision, revision); + assert_eq!(st.history.undo.len(), undo); + assert_eq!(s.journal.len(), journal); + assert!(designcraft_render::render_pdf_page(&st.doc.assets[&aid].data, 1, 64).is_some()); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn link_status_relink_update_embed() { + let dir = std::env::temp_dir().join(format!("dc-links-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + std::fs::write(&a, png(10, 10)).unwrap(); + std::fs::write(&b, png(20, 10)).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": a.to_string_lossy(), "x": 72, "y": 72, "width": 72})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(l[0]["status"], "ok"); + assert_eq!(l[0]["uses"][0]["ppi"], 10.0, "10 px over 1 inch"); + let aid = l[0]["asset"].as_u64().unwrap(); + // The file changes on disk. + std::fs::write(&a, png(30, 10)).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "modified"); + s.execute("links.update", &json!({})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(l[0]["status"], "ok"); + assert_eq!(l[0]["pixels"], json!([30, 10])); + // Missing, then relinked. + std::fs::remove_file(&a).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "missing"); + s.execute("links.relink", &json!({"asset": aid, "path": b.to_string_lossy()})).unwrap(); + let l = s.execute("links.list", &json!({})).unwrap(); + assert_eq!((l[0]["status"].as_str(), l[0]["name"].as_str()), (Some("ok"), Some("b.png"))); + // Go to selects the frame; embed forgets the file. + let r = s.execute("links.goTo", &json!({"asset": aid})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + s.execute("links.embed", &json!({"asset": aid})).unwrap(); + assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 74570f3fd..97c920309 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -92,40 +92,43 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { for t in &targets { let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; let span = para_span(&st.text, &t.range); - st.format_paras(span, |pf| { - let (now, _) = styles.resolve_para(pf); - // Off takes away this list only: a paragraph with the other list keeps it. - if !on && now.list_type != kind { - return; - } - let (style, _) = styles.resolve_para_style(&pf.style); - // The hanging indent this button set (and nothing has changed since). - let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG; - // A value: left to the style when it gives the same, else set here. - let mut set = |key: &str, v: Value, same: bool| { - let _ = pf.para.set_json(key, if same { &Value::Null } else { &v }); - }; - let list = if on { kind } else { ListType::None }; - set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list); - if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 { - set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG); - set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG); - set("listHang", json!(true), false); - } else if !on && now.list_type == kind { - // Only the hanging indent set here: one the paragraph had before, or its style - // gives, stays. - if own_hang { - set("leftIndent", json!(0.0), style.left_indent == 0.0); - set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); - } - set("listHang", Value::Null, true); - } - }); + st.format_paras(span, |pf| set_para_list(pf, &styles, on, kind)); } Ok(json!({"on": on})) }) } +/// One paragraph's share of [`apply_list`]: turns the list on or off, setting only the values the +/// style does not already give. +fn set_para_list(pf: &mut designcraft_doc::ParaFormat, styles: &designcraft_doc::Styles, on: bool, kind: ListType) { + let (now, _) = styles.resolve_para(pf); + // Off takes away this list only: a paragraph with the other list keeps it. + if !on && now.list_type != kind { + return; + } + let (style, _) = styles.resolve_para_style(&pf.style); + // The hanging indent this button set (and nothing has changed since). + let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG; + // A value: left to the style when it gives the same, else set here. + let mut set = |key: &str, v: Value, same: bool| { + let _ = pf.para.set_json(key, if same { &Value::Null } else { &v }); + }; + let list = if on { kind } else { ListType::None }; + set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list); + if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 { + set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG); + set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG); + set("listHang", json!(true), false); + } else if !on && now.list_type == kind { + // Only the hanging indent set here: one the paragraph had before, or its style gives, stays. + if own_hang { + set("leftIndent", json!(0.0), style.left_indent == 0.0); + set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); + } + set("listHang", Value::Null, true); + } +} + /// The range whose paragraphs a list applies to: a selection ending where a paragraph starts (after /// a return) doesn't reach into that paragraph. The story's last paragraph, empty after a final /// return, stays in when the range runs to the end (a whole frame). diff --git a/crates/engine/src/cmd/load_styles.rs b/crates/engine/src/cmd/load_styles.rs index b89b42fa5..762f7b1fc 100644 --- a/crates/engine/src/cmd/load_styles.rs +++ b/crates/engine/src/cmd/load_styles.rs @@ -350,358 +350,4 @@ fn list(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use designcraft_color::{Color, Swatch, SwatchValue}; - use designcraft_doc::{CharAttrs, CharacterStyle, ExportTag, GrepStyle, NumberedList, ParaAttrs, ParagraphStyle, Rule, Styles}; - use serde_json::{Value, json}; - - use crate::Session; - - const BASIC: &str = "[Basic Paragraph]"; - - fn para(name: &str, based_on: Option<&str>, size: f64) -> ParagraphStyle { - ParagraphStyle { - name: name.into(), - based_on: based_on.map(str::to_string), - next_style: None, - para: ParaAttrs::default(), - chars: CharAttrs { size: Some(size), ..Default::default() }, - shortcut: String::new(), - } - } - - fn chars(name: &str, based_on: Option<&str>, size: f64) -> CharacterStyle { - CharacterStyle { - name: name.into(), - based_on: based_on.map(str::to_string), - chars: CharAttrs { size: Some(size), ..Default::default() }, - shortcut: String::new(), - } - } - - fn new_doc() -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s - } - - fn file_of(s: &Session) -> String { - super::super::file::base64_encode(&super::super::file::to_bytes(&s.doc().unwrap().doc).unwrap()) - } - - /// A client's document: Body in the brand colour with a GREP style, Head and Quote based on - /// it, Small characters based on Lead, an 11 pt [Basic Paragraph], and a swatch no style uses. - fn client() -> Session { - let mut s = new_doc(); - s.edit(|d, _| { - d.swatches.push(Swatch::color("Brand Teal", Color::cmyk(0.9, 0.0, 0.4, 0.2))); - d.swatches.push(Swatch { - name: "Brand Teal 40%".into(), - value: SwatchValue::Tint { base: "Brand Teal".into(), tint: 0.4 }, - locked: false, - named: true, - hidden: false, - }); - d.swatches.push(Swatch::color("Unused Red", Color::cmyk(0.0, 1.0, 1.0, 0.0))); - let st = d.styles_mut(); - st.para_mut(BASIC).unwrap().chars.size = Some(11.0); - st.character.push(chars("Lead", None, 9.0)); - st.character.push(chars("Small", Some("Lead"), 7.0)); - let mut body = para("Body", Some(BASIC), 10.0); - body.chars.fill = Some("Brand Teal".into()); - body.para.grep_styles = Some(vec![GrepStyle { style: "Small".into(), pattern: r"\d+".into() }]); - st.paragraph.push(body); - let mut head = para("Head", Some("Body"), 20.0); - head.next_style = Some("Body".into()); - st.paragraph.push(head); - let mut quote = para("Quote", Some("Body"), 12.0); - quote.para.rule_above = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); - st.paragraph.push(quote); - Ok(()) - }) - .unwrap(); - s - } - - fn styles(s: &Session) -> Styles { - (*s.doc().unwrap().doc.styles).clone() - } - - #[test] - fn every_style_of_the_file_loads_with_the_swatches_it_uses() { - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - let d = &s.doc().unwrap().doc; - let head = d.styles.para("Head").unwrap(); - assert_eq!((head.based_on.as_deref(), head.next_style.as_deref(), head.chars.size), (Some("Body"), Some("Body"), Some(20.0))); - assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); - assert_eq!(d.styles.char_style("Small").unwrap().based_on.as_deref(), Some("Lead")); - assert_eq!(d.swatch("Brand Teal"), client().doc().unwrap().doc.swatch("Brand Teal")); - assert!(d.swatch("Brand Teal 40%").is_some()); - assert!(d.swatch("Unused Red").is_none(), "a swatch no loaded style uses stays behind"); - assert_eq!(r["paragraph"]["added"], json!(["Body", "Head", "Quote"])); - assert_eq!(r["character"]["added"], json!(["Lead", "Small"])); - assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); - } - - #[test] - fn basic_paragraph_takes_the_definition_of_the_file() { - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para(BASIC).unwrap().chars.size, Some(11.0)); - assert_eq!(r["paragraph"]["replaced"], json!([BASIC])); - } - - #[test] - fn a_named_style_brings_the_styles_it_names() { - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&client()), "paragraph": ["Head"], "character": []})).unwrap(); - let st = styles(&s); - assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"]), "Head is based on Body"); - assert_eq!(r["character"]["added"], json!(["Lead", "Small"]), "Body's GREP style names Small, which is based on Lead"); - assert!(st.para("Quote").is_none()); - assert_eq!(st.para(BASIC).unwrap().chars.size, None, "a style the document has is only replaced when it was asked for"); - assert_eq!(r["paragraph"]["replaced"], json!([])); - assert_eq!(r["swatches"], json!(["Brand Teal"])); - } - - /// The document already has a Body and a Lead of its own. - fn doc_with_body_and_lead() -> Session { - let mut s = new_doc(); - s.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("Body", None, 8.0)); - st.character.push(chars("Lead", None, 5.0)); - Ok(()) - }) - .unwrap(); - s - } - - #[test] - fn a_style_of_the_same_name_takes_the_loaded_definition() { - let mut s = doc_with_body_and_lead(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - let st = styles(&s); - assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(10.0), Some(9.0))); - assert_eq!(r["paragraph"]["replaced"], json!([BASIC, "Body"])); - assert_eq!(r["character"]["replaced"], json!(["Lead"])); - assert_eq!(st.paragraph.iter().filter(|p| p.name == "Body").count(), 1); - } - - #[test] - fn use_existing_keeps_the_styles_of_the_document() { - let mut s = doc_with_body_and_lead(); - let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "useExisting"})).unwrap(); - let st = styles(&s); - assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(8.0), Some(5.0))); - assert_eq!(st.para(BASIC).unwrap().chars.size, None); - assert_eq!(st.para("Head").unwrap().based_on.as_deref(), Some("Body")); - assert_eq!((&r["paragraph"]["added"], &r["paragraph"]["replaced"]), (&json!(["Head", "Quote"]), &json!([]))); - } - - #[test] - fn auto_rename_loads_a_clashing_style_under_a_new_name() { - let mut s = doc_with_body_and_lead(); - let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "autoRename"})).unwrap(); - let st = styles(&s); - assert_eq!((st.para("Body").unwrap().chars.size, st.para("Body 2").unwrap().chars.size), (Some(8.0), Some(10.0))); - assert_eq!((st.char_style("Lead").unwrap().chars.size, st.char_style("Lead 2").unwrap().chars.size), (Some(5.0), Some(9.0))); - let head = st.para("Head").unwrap(); - assert_eq!( - (head.based_on.as_deref(), head.next_style.as_deref()), - (Some("Body 2"), Some("Body 2")), - "the loaded Head follows the loaded Body" - ); - assert_eq!(st.char_style("Small").unwrap().based_on.as_deref(), Some("Lead 2")); - assert_eq!(st.para("Body 2").unwrap().based_on.as_deref(), Some("Basic Paragraph")); - assert_eq!(st.para("Basic Paragraph").unwrap().chars.size, Some(11.0), "a built-in style comes in without its brackets"); - assert_eq!(st.para(BASIC).unwrap().chars.size, None); - assert_eq!(r["paragraph"]["renamed"], json!({"[Basic Paragraph]": "Basic Paragraph", "Body": "Body 2"})); - assert_eq!(r["character"]["renamed"], json!({"Lead": "Lead 2"})); - assert_eq!(r["paragraph"]["replaced"], json!([])); - } - - #[test] - fn a_style_that_is_the_same_in_both_is_no_conflict() { - let mut s = new_doc(); - let file = file_of(&client()); - s.execute("style.load", &json!({"base64": file})).unwrap(); - let loaded = styles(&s); - for conflicts in ["redefine", "autoRename"] { - let r = s.execute("style.load", &json!({"base64": file, "conflicts": conflicts})).unwrap(); - let none = json!({"added": [], "replaced": [], "renamed": {}}); - assert_eq!(r, json!({"paragraph": none, "character": none, "swatches": []}), "{conflicts}"); - } - assert_eq!(styles(&s), loaded); - // Loading nothing leaves no undo step: one Undo takes back the first load. - s.execute("edit.undo", &json!({})).unwrap(); - assert!(styles(&s).para("Head").is_none()); - } - - #[test] - fn loading_is_one_undo_step() { - let mut s = doc_with_body_and_lead(); - let (before, swatches) = (styles(&s), s.doc().unwrap().doc.swatches.clone()); - s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - assert_ne!(styles(&s), before); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(styles(&s), before); - assert_eq!(s.doc().unwrap().doc.swatches, swatches); - } - - #[test] - fn a_swatch_of_the_document_wins_over_the_one_in_the_file() { - let mut s = new_doc(); - s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#ff0000"})).unwrap(); - let mine = s.doc().unwrap().doc.swatch("Brand Teal").cloned(); - let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); - assert_eq!(s.doc().unwrap().doc.swatch("Brand Teal").cloned(), mine); - assert_eq!(r["swatches"], json!(["Brand Teal 40%"])); - } - - #[test] - fn a_tint_swatch_brings_its_base_colour() { - let mut src = client(); - src.edit(|d, _| { - let mut ruled = para("Ruled", None, 9.0); - ruled.para.rule_below = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); - d.styles_mut().paragraph.push(ruled); - Ok(()) - }) - .unwrap(); - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Ruled"], "character": []})).unwrap(); - assert_eq!(r["paragraph"]["added"], json!(["Ruled"])); - assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); - } - - #[test] - fn export_tags_and_named_lists_come_with_their_styles() { - let mut src = client(); - src.edit(|d, _| { - d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false }); - d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() }); - let st = d.styles_mut(); - st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into() }); - st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new() }); - st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into()); - Ok(()) - }) - .unwrap(); - let mut s = doc_with_body_and_lead(); - s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!(d.styles.export_tag("Head", false).map(|t| t.tag.as_str()), Some("h1")); - assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name"); - assert_eq!(d.styles.export_tag("Lead", true), None); - assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::>(), [("Steps", false)]); - } - - #[test] - fn styles_load_from_an_idml_file() { - let idml = client().execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"base64": idml, "name": "Client.IDML", "paragraph": ["Head"]})).unwrap(); - let d = &s.doc().unwrap().doc; - let head = d.styles.para("Head").unwrap(); - assert_eq!((head.based_on.as_deref(), head.chars.size), (Some("Body"), Some(20.0))); - assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); - assert!(d.swatch("Brand Teal").is_some()); - assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"])); - } - - #[test] - fn styles_load_from_a_path() { - let path = std::env::temp_dir().join(format!("dc-load-styles-{}.designcraft", std::process::id())); - std::fs::write(&path, super::super::file::to_bytes(&client().doc().unwrap().doc).unwrap()).unwrap(); - let mut s = new_doc(); - let r = s.execute("style.load", &json!({"path": path.to_str().unwrap(), "paragraph": [], "character": ["Small"]})); - std::fs::remove_file(&path).unwrap(); - assert_eq!(r.unwrap()["character"]["added"], json!(["Lead", "Small"])); - assert!(styles(&s).para("Head").is_none()); - } - - #[test] - fn the_file_lists_its_styles_and_which_ones_clash() { - let mut s = doc_with_body_and_lead(); - let file = file_of(&client()); - let name = |n: &str, conflict: bool| json!({"name": n, "conflict": conflict}); - let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); - assert_eq!(r["paragraph"], json!([name(BASIC, true), name("Body", true), name("Head", false), name("Quote", false)])); - assert_eq!(r["character"], json!([name("Lead", true), name("Small", false)])); - s.execute("style.load", &json!({"base64": file})).unwrap(); - let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); - assert!(r["paragraph"].as_array().unwrap().iter().all(|p| p["conflict"] == false), "the same definition is no clash: {r}"); - } - - fn unchanged_after(s: &mut Session, params: Value, why: &str) { - let before = s.doc().unwrap().doc.clone(); - let e = s.execute("style.load", ¶ms).unwrap_err().to_string(); - assert!(e.contains(why), "{e}"); - assert_eq!(s.doc().unwrap().doc, before); - } - - #[test] - fn what_cannot_be_loaded_is_an_error_and_changes_nothing() { - let mut s = doc_with_body_and_lead(); - let file = file_of(&client()); - unchanged_after(&mut s, json!({}), "`path` or `base64`"); - unchanged_after(&mut s, json!({"base64": "AAAA"}), ""); - unchanged_after(&mut s, json!({"base64": "AAAA", "name": "a.idml"}), ""); - unchanged_after(&mut s, json!({"base64": file, "paragraph": ["Head", "Nope"]}), "no paragraph style `Nope`"); - unchanged_after(&mut s, json!({"base64": file, "character": ["[None]"]}), "no character style `[None]`"); - unchanged_after(&mut s, json!({"base64": file, "paragraph": ["[No Paragraph Style]"]}), "no paragraph style"); - unchanged_after(&mut s, json!({"base64": file, "paragraph": "Head"}), "`paragraph` must be a list of style names"); - unchanged_after(&mut s, json!({"base64": file, "character": [1]}), "`character` must be a list of style names"); - unchanged_after(&mut s, json!({"base64": file, "conflicts": "merge"}), "`conflicts`"); - } - - #[test] - fn a_style_that_would_be_based_on_itself_is_refused() { - // In the file B is based on A; in the document A is based on B. - let mut src = new_doc(); - src.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("A", None, 9.0)); - st.paragraph.push(para("B", Some("A"), 9.0)); - st.character.push(chars("X", None, 9.0)); - st.character.push(chars("Y", Some("X"), 9.0)); - Ok(()) - }) - .unwrap(); - let mut s = new_doc(); - s.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("B", None, 9.0)); - st.paragraph.push(para("A", Some("B"), 9.0)); - st.character.push(chars("Y", None, 9.0)); - st.character.push(chars("X", Some("Y"), 9.0)); - Ok(()) - }) - .unwrap(); - unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["B"], "character": []}), "`B` would be based on itself"); - unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": [], "character": ["Y"]}), "`Y` would be based on itself"); - // With A loaded as well both take the file's order. - s.execute("style.load", &json!({"base64": file_of(&src), "character": []})).unwrap(); - assert_eq!((styles(&s).para("A").unwrap().based_on.clone(), styles(&s).para("B").unwrap().based_on.clone()), (None, Some("A".to_string()))); - } - - #[test] - fn a_file_with_a_based_on_loop_of_its_own_does_not_hang() { - let mut src = new_doc(); - src.edit(|d, _| { - let st = d.styles_mut(); - st.paragraph.push(para("A", Some("B"), 9.0)); - st.paragraph.push(para("B", Some("A"), 9.0)); - st.paragraph.push(para("Next", None, 9.0)); - st.para_mut("Next").unwrap().next_style = Some("Next".into()); - Ok(()) - }) - .unwrap(); - let mut s = new_doc(); - unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["A"]}), "would be based on itself"); - let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Next"]})).unwrap(); - assert_eq!(r["paragraph"]["added"], json!(["Next"])); - } -} +mod tests; diff --git a/crates/engine/src/cmd/load_styles/tests.rs b/crates/engine/src/cmd/load_styles/tests.rs new file mode 100644 index 000000000..9b9db72ed --- /dev/null +++ b/crates/engine/src/cmd/load_styles/tests.rs @@ -0,0 +1,349 @@ +use designcraft_color::{Color, Swatch, SwatchValue}; +use designcraft_doc::{CharAttrs, CharacterStyle, ExportTag, GrepStyle, NumberedList, ParaAttrs, ParagraphStyle, Rule, Styles}; +use serde_json::{Value, json}; + +use crate::Session; + +const BASIC: &str = "[Basic Paragraph]"; + +fn para(name: &str, based_on: Option<&str>, size: f64) -> ParagraphStyle { + ParagraphStyle { + name: name.into(), + based_on: based_on.map(str::to_string), + next_style: None, + para: ParaAttrs::default(), + chars: CharAttrs { size: Some(size), ..Default::default() }, + shortcut: String::new(), + } +} + +fn chars(name: &str, based_on: Option<&str>, size: f64) -> CharacterStyle { + CharacterStyle { + name: name.into(), + based_on: based_on.map(str::to_string), + chars: CharAttrs { size: Some(size), ..Default::default() }, + shortcut: String::new(), + } +} + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} + +fn file_of(s: &Session) -> String { + super::super::file::base64_encode(&super::super::file::to_bytes(&s.doc().unwrap().doc).unwrap()) +} + +/// A client's document: Body in the brand colour with a GREP style, Head and Quote based on +/// it, Small characters based on Lead, an 11 pt [Basic Paragraph], and a swatch no style uses. +fn client() -> Session { + let mut s = new_doc(); + s.edit(|d, _| { + d.swatches.push(Swatch::color("Brand Teal", Color::cmyk(0.9, 0.0, 0.4, 0.2))); + d.swatches.push(Swatch { + name: "Brand Teal 40%".into(), + value: SwatchValue::Tint { base: "Brand Teal".into(), tint: 0.4 }, + locked: false, + named: true, + hidden: false, + }); + d.swatches.push(Swatch::color("Unused Red", Color::cmyk(0.0, 1.0, 1.0, 0.0))); + let st = d.styles_mut(); + st.para_mut(BASIC).unwrap().chars.size = Some(11.0); + st.character.push(chars("Lead", None, 9.0)); + st.character.push(chars("Small", Some("Lead"), 7.0)); + let mut body = para("Body", Some(BASIC), 10.0); + body.chars.fill = Some("Brand Teal".into()); + body.para.grep_styles = Some(vec![GrepStyle { style: "Small".into(), pattern: r"\d+".into() }]); + st.paragraph.push(body); + let mut head = para("Head", Some("Body"), 20.0); + head.next_style = Some("Body".into()); + st.paragraph.push(head); + let mut quote = para("Quote", Some("Body"), 12.0); + quote.para.rule_above = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); + st.paragraph.push(quote); + Ok(()) + }) + .unwrap(); + s +} + +fn styles(s: &Session) -> Styles { + (*s.doc().unwrap().doc.styles).clone() +} + +#[test] +fn every_style_of_the_file_loads_with_the_swatches_it_uses() { + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + let d = &s.doc().unwrap().doc; + let head = d.styles.para("Head").unwrap(); + assert_eq!((head.based_on.as_deref(), head.next_style.as_deref(), head.chars.size), (Some("Body"), Some("Body"), Some(20.0))); + assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); + assert_eq!(d.styles.char_style("Small").unwrap().based_on.as_deref(), Some("Lead")); + assert_eq!(d.swatch("Brand Teal"), client().doc().unwrap().doc.swatch("Brand Teal")); + assert!(d.swatch("Brand Teal 40%").is_some()); + assert!(d.swatch("Unused Red").is_none(), "a swatch no loaded style uses stays behind"); + assert_eq!(r["paragraph"]["added"], json!(["Body", "Head", "Quote"])); + assert_eq!(r["character"]["added"], json!(["Lead", "Small"])); + assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); +} + +#[test] +fn basic_paragraph_takes_the_definition_of_the_file() { + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para(BASIC).unwrap().chars.size, Some(11.0)); + assert_eq!(r["paragraph"]["replaced"], json!([BASIC])); +} + +#[test] +fn a_named_style_brings_the_styles_it_names() { + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&client()), "paragraph": ["Head"], "character": []})).unwrap(); + let st = styles(&s); + assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"]), "Head is based on Body"); + assert_eq!(r["character"]["added"], json!(["Lead", "Small"]), "Body's GREP style names Small, which is based on Lead"); + assert!(st.para("Quote").is_none()); + assert_eq!(st.para(BASIC).unwrap().chars.size, None, "a style the document has is only replaced when it was asked for"); + assert_eq!(r["paragraph"]["replaced"], json!([])); + assert_eq!(r["swatches"], json!(["Brand Teal"])); +} + +/// The document already has a Body and a Lead of its own. +fn doc_with_body_and_lead() -> Session { + let mut s = new_doc(); + s.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("Body", None, 8.0)); + st.character.push(chars("Lead", None, 5.0)); + Ok(()) + }) + .unwrap(); + s +} + +#[test] +fn a_style_of_the_same_name_takes_the_loaded_definition() { + let mut s = doc_with_body_and_lead(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + let st = styles(&s); + assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(10.0), Some(9.0))); + assert_eq!(r["paragraph"]["replaced"], json!([BASIC, "Body"])); + assert_eq!(r["character"]["replaced"], json!(["Lead"])); + assert_eq!(st.paragraph.iter().filter(|p| p.name == "Body").count(), 1); +} + +#[test] +fn use_existing_keeps_the_styles_of_the_document() { + let mut s = doc_with_body_and_lead(); + let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "useExisting"})).unwrap(); + let st = styles(&s); + assert_eq!((st.para("Body").unwrap().chars.size, st.char_style("Lead").unwrap().chars.size), (Some(8.0), Some(5.0))); + assert_eq!(st.para(BASIC).unwrap().chars.size, None); + assert_eq!(st.para("Head").unwrap().based_on.as_deref(), Some("Body")); + assert_eq!((&r["paragraph"]["added"], &r["paragraph"]["replaced"]), (&json!(["Head", "Quote"]), &json!([]))); +} + +#[test] +fn auto_rename_loads_a_clashing_style_under_a_new_name() { + let mut s = doc_with_body_and_lead(); + let r = s.execute("style.load", &json!({"base64": file_of(&client()), "conflicts": "autoRename"})).unwrap(); + let st = styles(&s); + assert_eq!((st.para("Body").unwrap().chars.size, st.para("Body 2").unwrap().chars.size), (Some(8.0), Some(10.0))); + assert_eq!((st.char_style("Lead").unwrap().chars.size, st.char_style("Lead 2").unwrap().chars.size), (Some(5.0), Some(9.0))); + let head = st.para("Head").unwrap(); + assert_eq!((head.based_on.as_deref(), head.next_style.as_deref()), (Some("Body 2"), Some("Body 2")), "the loaded Head follows the loaded Body"); + assert_eq!(st.char_style("Small").unwrap().based_on.as_deref(), Some("Lead 2")); + assert_eq!(st.para("Body 2").unwrap().based_on.as_deref(), Some("Basic Paragraph")); + assert_eq!(st.para("Basic Paragraph").unwrap().chars.size, Some(11.0), "a built-in style comes in without its brackets"); + assert_eq!(st.para(BASIC).unwrap().chars.size, None); + assert_eq!(r["paragraph"]["renamed"], json!({"[Basic Paragraph]": "Basic Paragraph", "Body": "Body 2"})); + assert_eq!(r["character"]["renamed"], json!({"Lead": "Lead 2"})); + assert_eq!(r["paragraph"]["replaced"], json!([])); +} + +#[test] +fn a_style_that_is_the_same_in_both_is_no_conflict() { + let mut s = new_doc(); + let file = file_of(&client()); + s.execute("style.load", &json!({"base64": file})).unwrap(); + let loaded = styles(&s); + for conflicts in ["redefine", "autoRename"] { + let r = s.execute("style.load", &json!({"base64": file, "conflicts": conflicts})).unwrap(); + let none = json!({"added": [], "replaced": [], "renamed": {}}); + assert_eq!(r, json!({"paragraph": none, "character": none, "swatches": []}), "{conflicts}"); + } + assert_eq!(styles(&s), loaded); + // Loading nothing leaves no undo step: one Undo takes back the first load. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(styles(&s).para("Head").is_none()); +} + +#[test] +fn loading_is_one_undo_step() { + let mut s = doc_with_body_and_lead(); + let (before, swatches) = (styles(&s), s.doc().unwrap().doc.swatches.clone()); + s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + assert_ne!(styles(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(styles(&s), before); + assert_eq!(s.doc().unwrap().doc.swatches, swatches); +} + +#[test] +fn a_swatch_of_the_document_wins_over_the_one_in_the_file() { + let mut s = new_doc(); + s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#ff0000"})).unwrap(); + let mine = s.doc().unwrap().doc.swatch("Brand Teal").cloned(); + let r = s.execute("style.load", &json!({"base64": file_of(&client())})).unwrap(); + assert_eq!(s.doc().unwrap().doc.swatch("Brand Teal").cloned(), mine); + assert_eq!(r["swatches"], json!(["Brand Teal 40%"])); +} + +#[test] +fn a_tint_swatch_brings_its_base_colour() { + let mut src = client(); + src.edit(|d, _| { + let mut ruled = para("Ruled", None, 9.0); + ruled.para.rule_below = Some(Rule { on: true, color: "Brand Teal 40%".into(), ..Default::default() }); + d.styles_mut().paragraph.push(ruled); + Ok(()) + }) + .unwrap(); + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Ruled"], "character": []})).unwrap(); + assert_eq!(r["paragraph"]["added"], json!(["Ruled"])); + assert_eq!(r["swatches"], json!(["Brand Teal", "Brand Teal 40%"])); +} + +#[test] +fn export_tags_and_named_lists_come_with_their_styles() { + let mut src = client(); + src.edit(|d, _| { + d.settings.lists.push(NumberedList { name: "Steps".into(), continue_across_stories: false }); + d.settings.lists.push(NumberedList { name: "Unused".into(), ..Default::default() }); + let st = d.styles_mut(); + st.export_tags.insert("p:Head".into(), ExportTag { tag: "h1".into(), class: "title".into(), split: true }); + st.export_tags.insert("c:Lead".into(), ExportTag { tag: "em".into(), class: String::new(), ..Default::default() }); + st.para_mut("Quote").unwrap().para.list_name = Some("Steps".into()); + Ok(()) + }) + .unwrap(); + let mut s = doc_with_body_and_lead(); + s.execute("style.load", &json!({"base64": file_of(&src), "conflicts": "autoRename"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.styles.export_tag("Head", false).map(|t| (t.tag.as_str(), t.split)), Some(("h1", true)), "Split Document comes along"); + assert_eq!(d.styles.export_tag("Lead 2", true).map(|t| t.tag.as_str()), Some("em"), "the tag follows the new name"); + assert_eq!(d.styles.export_tag("Lead", true), None); + assert_eq!(d.settings.lists.iter().map(|l| (l.name.as_str(), l.continue_across_stories)).collect::>(), [("Steps", false)]); +} + +#[test] +fn styles_load_from_an_idml_file() { + let idml = client().execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"base64": idml, "name": "Client.IDML", "paragraph": ["Head"]})).unwrap(); + let d = &s.doc().unwrap().doc; + let head = d.styles.para("Head").unwrap(); + assert_eq!((head.based_on.as_deref(), head.chars.size), (Some("Body"), Some(20.0))); + assert_eq!(d.styles.para("Body").unwrap().chars.fill.as_deref(), Some("Brand Teal")); + assert!(d.swatch("Brand Teal").is_some()); + assert_eq!(r["paragraph"]["added"], json!(["Body", "Head"])); +} + +#[test] +fn styles_load_from_a_path() { + let path = std::env::temp_dir().join(format!("dc-load-styles-{}.designcraft", std::process::id())); + std::fs::write(&path, super::super::file::to_bytes(&client().doc().unwrap().doc).unwrap()).unwrap(); + let mut s = new_doc(); + let r = s.execute("style.load", &json!({"path": path.to_str().unwrap(), "paragraph": [], "character": ["Small"]})); + std::fs::remove_file(&path).unwrap(); + assert_eq!(r.unwrap()["character"]["added"], json!(["Lead", "Small"])); + assert!(styles(&s).para("Head").is_none()); +} + +#[test] +fn the_file_lists_its_styles_and_which_ones_clash() { + let mut s = doc_with_body_and_lead(); + let file = file_of(&client()); + let name = |n: &str, conflict: bool| json!({"name": n, "conflict": conflict}); + let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); + assert_eq!(r["paragraph"], json!([name(BASIC, true), name("Body", true), name("Head", false), name("Quote", false)])); + assert_eq!(r["character"], json!([name("Lead", true), name("Small", false)])); + s.execute("style.load", &json!({"base64": file})).unwrap(); + let r = s.execute("style.load.list", &json!({"base64": file})).unwrap(); + assert!(r["paragraph"].as_array().unwrap().iter().all(|p| p["conflict"] == false), "the same definition is no clash: {r}"); +} + +fn unchanged_after(s: &mut Session, params: Value, why: &str) { + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("style.load", ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn what_cannot_be_loaded_is_an_error_and_changes_nothing() { + let mut s = doc_with_body_and_lead(); + let file = file_of(&client()); + unchanged_after(&mut s, json!({}), "`path` or `base64`"); + unchanged_after(&mut s, json!({"base64": "AAAA"}), ""); + unchanged_after(&mut s, json!({"base64": "AAAA", "name": "a.idml"}), ""); + unchanged_after(&mut s, json!({"base64": file, "paragraph": ["Head", "Nope"]}), "no paragraph style `Nope`"); + unchanged_after(&mut s, json!({"base64": file, "character": ["[None]"]}), "no character style `[None]`"); + unchanged_after(&mut s, json!({"base64": file, "paragraph": ["[No Paragraph Style]"]}), "no paragraph style"); + unchanged_after(&mut s, json!({"base64": file, "paragraph": "Head"}), "`paragraph` must be a list of style names"); + unchanged_after(&mut s, json!({"base64": file, "character": [1]}), "`character` must be a list of style names"); + unchanged_after(&mut s, json!({"base64": file, "conflicts": "merge"}), "`conflicts`"); +} + +#[test] +fn a_style_that_would_be_based_on_itself_is_refused() { + // In the file B is based on A; in the document A is based on B. + let mut src = new_doc(); + src.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("A", None, 9.0)); + st.paragraph.push(para("B", Some("A"), 9.0)); + st.character.push(chars("X", None, 9.0)); + st.character.push(chars("Y", Some("X"), 9.0)); + Ok(()) + }) + .unwrap(); + let mut s = new_doc(); + s.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("B", None, 9.0)); + st.paragraph.push(para("A", Some("B"), 9.0)); + st.character.push(chars("Y", None, 9.0)); + st.character.push(chars("X", Some("Y"), 9.0)); + Ok(()) + }) + .unwrap(); + unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["B"], "character": []}), "`B` would be based on itself"); + unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": [], "character": ["Y"]}), "`Y` would be based on itself"); + // With A loaded as well both take the file's order. + s.execute("style.load", &json!({"base64": file_of(&src), "character": []})).unwrap(); + assert_eq!((styles(&s).para("A").unwrap().based_on.clone(), styles(&s).para("B").unwrap().based_on.clone()), (None, Some("A".to_string()))); +} + +#[test] +fn a_file_with_a_based_on_loop_of_its_own_does_not_hang() { + let mut src = new_doc(); + src.edit(|d, _| { + let st = d.styles_mut(); + st.paragraph.push(para("A", Some("B"), 9.0)); + st.paragraph.push(para("B", Some("A"), 9.0)); + st.paragraph.push(para("Next", None, 9.0)); + st.para_mut("Next").unwrap().next_style = Some("Next".into()); + Ok(()) + }) + .unwrap(); + let mut s = new_doc(); + unchanged_after(&mut s, json!({"base64": file_of(&src), "paragraph": ["A"]}), "would be based on itself"); + let r = s.execute("style.load", &json!({"base64": file_of(&src), "paragraph": ["Next"]})).unwrap(); + assert_eq!(r["paragraph"]["added"], json!(["Next"])); +} diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs index b684f2457..e5342f79c 100644 --- a/crates/engine/src/cmd/mathexpr.rs +++ b/crates/engine/src/cmd/mathexpr.rs @@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate(at), diff --git a/crates/engine/src/cmd/media.rs b/crates/engine/src/cmd/media.rs index 9e26fb0b4..a4e3f5ae6 100644 --- a/crates/engine/src/cmd/media.rs +++ b/crates/engine/src/cmd/media.rs @@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?; let r = it.inner_bounds(); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (r.width(), r.height()), xf: Affine::translate((r.x0, r.y0)), @@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str, let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?; let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); it.content = Content::Graphic(Graphic { + layer_options: None, asset: aid, size: (w, h), xf: Affine::translate((x, y)), diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index 9318458de..008f0a98f 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -59,7 +59,7 @@ fn in_note(s: &Session) -> std::result::Result<(), String> { fn insert(s: &mut Session, p: &Value) -> Result { let text = super::text_param(p, "text"); - s.edit(|d, sel| { + let r = s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?; let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() }; let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; @@ -69,7 +69,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { let id = st.insert_note(r.start, &text, para).ok_or_else(|| bad("footnote.insert", "the story's footnotes don't match their references"))?; sel.text = Some(TextSel { story: t.story, anchor: text.len(), focus: text.len(), frame: t.frame, cell: Some(CellAddr::footnote(id)) }); Ok(json!({"story": t.story.0, "id": id})) - }) + })?; + s.typing_format = None; + Ok(r) } /// Overlay `p` onto `base` (objects merge key by key). @@ -115,6 +117,16 @@ fn note_id(p: &Value, cmd: &str) -> Result { p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing id")) } +/// Moves a caret or selection in story `sid`'s own text (not a cell or note) back by the length of `range` when it lies +/// after `range.start`, or to `range.start` when it lies inside `range`. +pub(super) fn follow_delete(sel: &mut designcraft_doc::Selection, sid: StoryId, range: std::ops::Range) { + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell.is_none()) { + let back = |i: usize| if i > range.start { i - (i - range.start).min(range.len()) } else { i }; + t.anchor = back(t.anchor); + t.focus = back(t.focus); + } +} + fn list(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let only = p.get("story").and_then(Value::as_u64).map(StoryId); @@ -131,11 +143,15 @@ fn set_text(s: &mut Session, p: &Value) -> Result { let sid = story_id(p, "footnote.setText")?; let id = note_id(p, "footnote.setText")?; let text = super::text_param(p, "text"); - s.edit(|d, _| { + s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?; let len = n.text.len(); n.text.replace(0..len, &text); + // A caret in the replaced text goes to its end. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))) { + (t.anchor, t.focus) = (n.text.len(), n.text.len()); + } Ok(Value::Null) }) } @@ -143,15 +159,21 @@ fn set_text(s: &mut Session, p: &Value) -> Result { fn delete(s: &mut Session, p: &Value) -> Result { let sid = story_id(p, "footnote.delete")?; let id = note_id(p, "footnote.delete")?; - s.edit(|d, sel| { + let moved = s.edit(|d, sel| { let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let at = st.note_anchor(id).ok_or_else(|| bad("footnote.delete", format!("no footnote {id}")))?; + follow_delete(sel, sid, at..at + designcraft_doc::FOOTNOTE_REF.len_utf8()); st.delete(at..at + designcraft_doc::FOOTNOTE_REF.len_utf8()); - if sel.text.is_some_and(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))) { + let moved = sel.text.is_some_and(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))); + if moved { sel.text = Some(TextSel::caret(sid, at)); } - Ok(Value::Null) - }) + Ok(moved) + })?; + if moved { + s.typing_format = None; + } + Ok(Value::Null) } fn go_to_reference(s: &mut Session, _: &Value) -> Result { @@ -162,6 +184,7 @@ fn go_to_reference(s: &mut Session, _: &Value) -> Result { let pos = at + designcraft_doc::FOOTNOTE_REF.len_utf8(); st.selection.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); st.bump_revision(); + s.typing_format = None; Ok(json!({"story": t.story.0, "pos": pos})) } @@ -213,6 +236,36 @@ mod tests { assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes.len(), 1); } + #[test] + fn footnote_caret_moves_drop_the_pending_typing_format() { + let (mut s, sid) = session_with_text(); + let pending = |s: &mut Session| { + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + assert!(s.typing_format.is_some()); + }; + // Formatting chosen in footnote text, then away to the reference and back to the same caret (a new + // footnote takes the deleted one's id): typing there is in the footnote text's own format. + s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap(); + let id = s.execute("footnote.insert", &json!({"text": "A source."})).unwrap()["id"].as_u64().unwrap(); + pending(&mut s); + s.execute("footnote.goToReference", &json!({})).unwrap(); + s.execute("footnote.delete", &json!({"story": sid.0, "id": id})).unwrap(); + let again = s.execute("footnote.insert", &json!({"text": "A source."})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(again, id); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes[0].text.char_format_at(10).over.size, None); + // Each command that moves the caret drops it. + pending(&mut s); + s.execute("footnote.goToReference", &json!({})).unwrap(); + assert!(s.typing_format.is_none(), "footnote.goToReference"); + pending(&mut s); + let id = s.execute("footnote.insert", &json!({"text": "y"})).unwrap()["id"].as_u64().unwrap(); + assert!(s.typing_format.is_none(), "footnote.insert"); + pending(&mut s); + s.execute("footnote.delete", &json!({"story": sid.0, "id": id})).unwrap(); + assert!(s.typing_format.is_none(), "footnote.delete"); + } + #[test] fn not_in_cells() { let (mut s, sid) = session_with_text(); @@ -221,4 +274,46 @@ mod tests { // Caret is now in footnote text: no nested footnotes. assert!(s.execute("footnote.insert", &json!({})).is_err()); } + + #[test] + fn huge_start_at_does_not_overflow() { + let (mut s, sid) = session_with_text(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap(); + s.execute("footnote.insert", &json!({"text": "x"})).unwrap(); + s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, None); + assert_eq!(cs.frames[0].notes.len(), 1); + } + + #[test] + fn deleting_a_footnote_keeps_the_caret_on_its_character() { + let n = designcraft_doc::FOOTNOTE_REF.len_utf8(); + // (anchor, focus) in the story text before the delete, then after it. + for (before, after) in [((17 + n, 21 + n), (17, 21)), ((5, 21 + n), (5, 21)), ((5, 5), (5, 5)), ((16 + n, 16 + n), (16, 16))] { + let (mut s, sid) = session_with_text(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap(); + let id = s.execute("footnote.insert", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": before.0, "focus": before.1})).unwrap(); + s.execute("footnote.delete", &json!({"story": sid.0, "id": id})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), after, "selected {before:?}"); + } + } + + #[test] + fn replacing_footnote_text_puts_its_caret_at_the_end() { + let (mut s, sid) = session_with_text(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap(); + let id = s.execute("footnote.insert", &json!({"text": "abcd"})).unwrap()["id"].as_u64().unwrap(); + // The caret between "c" and "d" is inside the second "é" of the new text. + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.cell, Some(CellAddr::footnote(id))); + s.doc_mut().unwrap().selection.text = Some(TextSel { anchor: 3, focus: 3, ..t }); + s.execute("footnote.setText", &json!({"story": sid.0, "id": id, "text": "éé"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (4, 4)); + s.execute("text.delete", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes[0].text.text, "é"); + } } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 9bb4c5604..9cff3ac51 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -596,7 +596,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, ids?} — every effect at once, one undo step", + "{effects?: {dropShadow?: {on?, color?, opacity?, angle?, globalLight?, distance?, size?, spread?}, innerShadow?, outerGlow?, innerGlow? {on?, color?, opacity?, size?, choke?, center?, mode? (blend mode: Screen, Multiply, …; Screen by default), technique?: softer|precise}, bevel?, satin?, feather? (width), featherOptions? {choke? (%), corners?: sharp|rounded|diffused}, directionalFeather?, gradientFeather?} (the fields given replace the object's, as the Effects dialog sets them), opacity?: 0..1, blend?, globalLight? (the document's global light angle, as an Angle field with Use Global Light sets it), ids?} — every effect at once, one undo step", has_selection, effects_all ), @@ -610,7 +610,7 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", has_selection, text_frame_options ), @@ -2305,8 +2305,16 @@ fn effects_all(s: &mut Session, p: &Value) -> Result { Some(BlendMode::parse(raw).ok_or_else(|| bad(ID, format!("unknown blend mode `{raw}`")))?) } }; + // The document's global light angle, changed from an Angle field with Use Global Light on. + let global_light = match p.get("globalLight") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad(ID, "globalLight must be a number"))?), + }; let ids = targets(s, p)?; s.edit(|d, _| { + if let Some(a) = global_light { + d.settings.global_light = (a + 180.0).rem_euclid(360.0) - 180.0; + } let mut changed = 0; for id in &ids { let Some(it) = d.item_mut(*id) else { continue }; @@ -2518,6 +2526,41 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { as u8, ), }; + // `Some(None)` turns the frame's custom baseline grid off. + let custom_grid = match p.get("baselineGrid") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::Object(g)) => { + let num = |key: &str, lo: f64, hi: f64| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && (lo..=hi).contains(&x) => Ok(Some(x)), + _ => Err(bad(ID, format!("baselineGrid.{key} must be a number of points from {lo} to {hi}, not {v}"))), + }, + }; + let start = num("start", -15552.0, 15552.0)?; + let increment = num("increment", 0.0, 15552.0)?; + let relative_to = match g.get("relativeTo") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_str() + .and_then(designcraft_doc::FrameGridRelative::from_name) + .ok_or_else(|| bad(ID, format!("baselineGrid.relativeTo must be page|margin|frame|inset, not {v}")))?, + ), + }; + Some(Some((start, increment, relative_to))) + } + Some(v) => return Err(bad(ID, format!("baselineGrid must be an object or null, not {v}"))), + }; + // A frame without a custom grid starts from the document's. + let doc_grid = { + let g = &st.doc.settings.baseline_grid; + let relative_to = match g.relative_to { + designcraft_doc::GridRelative::TopOfPage => designcraft_doc::FrameGridRelative::TopOfPage, + designcraft_doc::GridRelative::TopMargin => designcraft_doc::FrameGridRelative::TopMargin, + }; + designcraft_doc::FrameBaselineGrid { start: g.start, increment: g.increment, relative_to } + }; let rule_color = match p.get("columnRuleColor") { None | Some(Value::Null) => None, Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()), @@ -2608,6 +2651,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if let Some(v) = rule_bottom { o.column_rule_bottom_inset = v; } + apply_custom_frame_grid(o, custom_grid, doc_grid); // Fixed Width columns set the frame's width: n columns, the gutters between them and // the left and right insets. let fixed_width = o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth && o.column_width > 0.0; @@ -2629,6 +2673,24 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { }) } +fn apply_custom_frame_grid( + options: &mut TextFrameOptions, + custom: Option, Option, Option)>>, + document_grid: designcraft_doc::FrameBaselineGrid, +) { + match custom { + Some(Some((start, increment, relative_to))) => { + let mut grid = options.baseline_grid.unwrap_or(document_grid); + grid.start = start.unwrap_or(grid.start); + grid.increment = increment.unwrap_or(grid.increment); + grid.relative_to = relative_to.unwrap_or(grid.relative_to); + options.baseline_grid = Some(grid); + } + Some(None) => options.baseline_grid = None, + None => {} + } +} + /// The gap at `p` on spread `sr`: (vertical gap?, edge a, edge b, ids ending at a, ids starting at b). pub(crate) fn find_gap(d: &Document, sr: designcraft_doc::SpreadRef, p: Point) -> Option<(bool, f64, f64, Vec, Vec)> { let sp = d.spread(sr)?; @@ -4150,6 +4212,26 @@ mod light_tests { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(angle(&s), 45.0); } + + /// The Effects dialog's Angle with Use Global Light on changes the global light, with the + /// effects, in one undo step. + #[test] + fn effects_sets_the_global_light_with_the_effects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [60, 10, 90, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.dropShadow", &json!({"on": true, "globalLight": true, "ids": [b]})).unwrap(); + let before = s.doc().unwrap().doc.settings.global_light; + s.execute("object.effects", &json!({"ids": [a], "effects": {"dropShadow": {"on": true, "globalLight": true}}, "globalLight": 30})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.global_light, 30.0); + let other = &d.item(ItemId(b)).unwrap().effects.drop_shadow; + assert_eq!(d.light_angle(other.angle, other.global_light), 30.0, "every shadow on Global Light follows"); + assert!(s.execute("object.effects", &json!({"ids": [a], "globalLight": "up"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.global_light, before, "one undo step"); + } } #[cfg(test)] @@ -4212,27 +4294,6 @@ mod attributes_tests { mod fitting_tests { use super::*; - #[test] - fn separations_come_from_source_colours() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let c = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "C=100 M=0 Y=0 K=0", "ids": [c]})).unwrap(); - let k = s.execute("frame.create", &json!({"rect": [300, 100, 400, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [k]})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let plate = |p: u8| { - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&d, &s.cache, 0, 1.0, false, &designcraft_render::RenderOptions { plate: Some(p), ..Default::default() }).unwrap() - }; - let (cyan, black) = (plate(0), plate(3)); - // Cyan ink only on the cyan frame; black ink only on the black one (no rich-black guess). - assert!(cyan.pixel(150, 150)[0] < 10 && cyan.pixel(350, 150)[0] > 245, "{:?} {:?}", cyan.pixel(150, 150), cyan.pixel(350, 150)); - assert!(black.pixel(350, 150)[0] < 10 && black.pixel(150, 150)[0] > 245); - assert!(cyan.pixel(20, 20)[0] > 245, "paper has no ink"); - } - #[test] fn inner_glow_bevel_and_satin_change_the_inside() { let mut s = Session::new(); @@ -4272,13 +4333,14 @@ mod fitting_tests { let df = shot(&s); assert!(df.pixel(102, 200)[0] > 200, "left edge faded to paper: {:?}", df.pixel(102, 200)); assert_eq!(df.pixel(298, 200), plain.pixel(298, 200), "right edge untouched"); - // A knockout group exports as an image too. + // A knockout group stays vector, as a knockout transparency group. let other = s.execute("frame.create", &json!({"rect": [400, 100, 450, 150]})).unwrap()["id"].clone(); let other2 = s.execute("frame.create", &json!({"rect": [420, 120, 470, 170]})).unwrap()["id"].clone(); let g = s.execute("object.group", &json!({"ids": [other, other2]})).unwrap()["id"].clone(); s.execute("object.transparencyGroup", &json!({"ids": [g], "knockout": true})).unwrap(); let r = s.execute("file.exportPdf", &json!({})).unwrap(); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("2 object(s)")), "{r}"); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 object(s)")), "{r}"); + assert!(String::from_utf8_lossy(&super::super::file::base64_decode(r["base64"].as_str().unwrap())).contains("/K true")); // PDF export carries effects (as an image of the object). let r = s.execute("file.exportPdf", &json!({})).unwrap(); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("soft effects")), "{r}"); @@ -5660,3 +5722,33 @@ mod fill_tint_tests { assert!(stroke_of(&s, b).is_none()); } } + +#[cfg(test)] +mod frame_baseline_grid_tests { + use super::*; + use designcraft_doc::{FrameBaselineGrid, FrameGridRelative}; + + #[test] + fn text_frame_options_set_a_custom_baseline_grid_relative_to() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + let grid = |s: &Session| s.doc().unwrap().doc.item(id).and_then(|i| i.text_frame()).and_then(|t| t.options.baseline_grid); + // Turning it on with only Relative To takes the document grid's start and increment. + let doc = s.doc().unwrap().doc.settings.baseline_grid.clone(); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "frame"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: doc.start, increment: doc.increment, relative_to: FrameGridRelative::TopOfFrame })); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"start": 10, "increment": 13, "relativeTo": "inset"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopInset })); + // Fields not given keep the frame's. + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": {"relativeTo": "margin"}})).unwrap(); + assert_eq!(grid(&s), Some(FrameBaselineGrid { start: 10.0, increment: 13.0, relative_to: FrameGridRelative::TopMargin })); + for bad in [json!({"relativeTo": "spine"}), json!({"start": f64::MAX}), json!({"increment": -1}), json!(3)] { + assert!(s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": bad})).is_err(), "{bad}"); + } + assert_eq!(grid(&s).map(|g| g.relative_to), Some(FrameGridRelative::TopMargin)); + s.execute("object.textFrameOptions", &json!({"ids": [id.0], "baselineGrid": null})).unwrap(); + assert_eq!(grid(&s), None); + } +} diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index d0ed58b03..a6946be4f 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -427,12 +427,19 @@ fn package(s: &mut Session, p: &Value) -> Result { .iter() .map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files))) .collect(); + // The IDML copy links the placed files, so a per-placement PDF layer choice cannot travel in + // it (an external-only IDML export rejects one for the same reason). Say so in the report + // instead of leaving a silent difference between the package's copies. + let mut idml_layer_note = ""; if p.get("idml").and_then(Value::as_bool).unwrap_or(true) { let path = package_output_path(&dir, &title, "idml")?; // The images are in Links/ beside it: link them there, without embedded copies. let opts = designcraft_idml::ExportOptions { embed_images: false }; root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?; files.push(path); + if super::interchange::has_applicable_pdf_layer_overrides(&packed) { + idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n"; + } } if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) { let path = package_output_path(&dir, &title, "pdf")?; @@ -456,6 +463,7 @@ fn package(s: &mut Session, p: &Value) -> Result { for l in links.as_array().into_iter().flatten() { r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or("")); } + r += idml_layer_note; if let Some(issues) = pre.get("issues").and_then(Value::as_array) { r += &format!("\nPreflight: {} issue(s)\n", issues.len()); for i in issues { @@ -482,444 +490,4 @@ fn copy_links(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn relocated_packages_rebind_embedded_image_links_on_open() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); - let original = dir.join("original"); - let moved = dir.join("moved"); - let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); - s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); - s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); - std::fs::rename(&original, &moved).unwrap(); - let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); - for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { - let mut opened = Session::new(); - opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); - let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); - assert_eq!(*asset.data, png, "{extension}: the packaged pixels"); - assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); - let links = opened.execute("links.list", &json!({})).unwrap(); - assert_eq!(links[0]["status"], "ok", "{extension}"); - } - std::fs::remove_dir_all(dir).unwrap(); - } - - /// The package's IDML links its images to the files in the package's Links folder and embeds - /// no copies of them. - #[test] - fn package_idml_links_the_links_folder_and_embeds_nothing() { - let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("dc-package-idml-links-{}-{nonce}", std::process::id())); - let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "LinkedPackage", "width": 72, "height": 72})).unwrap(); - s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); - let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap(); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap(); - let mut spreads = String::new(); - for i in 0..zip.len() { - let mut f = zip.by_index(i).unwrap(); - if f.name().starts_with("Spreads/") { - std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); - } - } - // Relative to the package: no path from the machine that made it (#340). - let uri = "file:Links/my%20photo.png"; - let folder = dir.file_name().unwrap().to_string_lossy().into_owned(); - assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}"); - assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); - assert!(!spreads.contains(""##; - let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); - std::fs::write(&gone, svg).unwrap(); - std::fs::write(&kept, svg.replace(""##).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); - s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); - let out = dir.join("Pkg"); - let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); - let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); - assert!(files.iter().any(|f| f.ends_with(".designcraft"))); - assert!(files.iter().any(|f| f.ends_with(".idml"))); - assert!(files.iter().any(|f| f.ends_with(".pdf"))); - assert!(out.join("Links").join("logo.svg").exists()); - let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); - assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); - assert!(report.contains("Preflight:"), "{report}"); - // The packaged document links to its Links folder. - let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); - let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); - let link = packed.assets.values().next().unwrap().link.clone().unwrap(); - assert!(link.contains("Links"), "{link}"); - // Copy Links To relinks the open document. - let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); - assert_eq!(r["copied"], 1); - assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_copies_the_fonts_their_licences_allow() { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - use designcraft_fonts::testing::{font_with, with_fs_type}; - const FREE: &str = "DocFont Package Free"; - const RESTRICTED: &str = "DocFont Package Restricted"; - let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); - std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); - } - // Text in both fonts, and in the bundled default font. - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap(); - for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] { - s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap(); - s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); - } - let src = dir.join("src").join("Fonts.designcraft"); - s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); - let mut s = Session::new(); - assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2); - - let out = dir.join("Pkg"); - let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); - let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); - assert!(packed_fonts.join("free.ttf").exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - assert_eq!( - std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, - std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 - ); - } - assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); - assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); - let report = r["report"].as_str().unwrap(); - assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); - assert!(report.contains(&format!("{FREE} Regular\n")), "{report}"); - assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}"); - // The packaged document opens with its fonts. - let doc = r["files"][0].as_str().unwrap().to_string(); - assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1); - // Packaging into the folder the fonts come from leaves them whole. - let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len(); - s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap(); - assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_copies_the_fallback_fonts_that_draw_text() { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - use designcraft_fonts::testing::{font_with, with_fs_type}; - const NAMED: &str = "DocFont Fallback Named"; - const HELPER: &str = "DocFont Fallback Helper"; - const RESTRICTED: &str = "DocFont Fallback Restricted"; - // Characters no other font has: the named font lacks both, the document's other fonts - // draw them. - let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}'); - let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); - std::fs::create_dir_all(&fonts).unwrap(); - std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap(); - std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap(); - std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); - let text = format!("N{helped}{restricted}"); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); - s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); - let src = dir.join("src").join("Fallback.designcraft"); - // Written without File › Save's preview: drawing it before the document's fonts load would - // pull in a system font that covers these characters (Unifont CSUR does) and draws them - // ahead of the document's own fonts in this process. - std::fs::write(&src, crate::cmd::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); - let mut s = Session::new(); - assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); - - let out = dir.join("Pkg"); - let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); - let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); - assert!(packed_fonts.join("named.ttf").exists()); - assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied"); - assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too"); - let report = r["report"].as_str().unwrap(); - let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}")); - assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}"); - assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); - assert!(!fallback.contains(NAMED), "{report}"); - // The packaged document draws the same text with its fonts. - let doc = r["files"][0].as_str().unwrap().to_string(); - assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn packaged_fonts_keep_a_font_extension() { - use designcraft_fonts::DOCUMENT_FONTS_FOLDER; - use designcraft_fonts::testing::{font_with, installed_face}; - // A font service's file without an extension is copied under a name the reopened - // package loads. - let dir = std::env::temp_dir().join(format!("dc-package-font-ext-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let from = dir.join(".12345"); - let bytes = font_with("DocFont Package Extensionless", &['E']).unwrap(); - std::fs::write(&from, &bytes).unwrap(); - let face = installed_face(bytes, from).unwrap(); - let root = OutputRoot::new(dir.clone()).unwrap(); - let fonts = dir.join("Pkg").join(DOCUMENT_FONTS_FOLDER); - std::fs::create_dir_all(&fonts).unwrap(); - let (mut copied, mut taken, mut files) = (Vec::new(), Vec::new(), Vec::new()); - assert_eq!(copy_font(&root, &face, &fonts, &mut copied, &mut taken, &mut files), ""); - assert_eq!(files, [fonts.join("12345.ttf")]); - let db = designcraft_fonts::FontDb::global(); - let loaded = db.load_document_fonts(&fonts, &|_| Ok(())); - assert_eq!((loaded.faces, loaded.skipped.len()), (1, 0), "{:?}", loaded.skipped); - db.close_scope(loaded.scope); - // The first four bytes name the kind; a font extension or an unknown kind stays. - assert_eq!(font_file_name("Face".into(), b"OTTO...."), "Face.otf"); - assert_eq!(font_file_name("Faces".into(), b"ttcf...."), "Faces.ttc"); - assert_eq!(font_file_name("Mac".into(), b"true...."), "Mac.ttf"); - assert_eq!(font_file_name("..".into(), &[0, 1, 0, 0]), "font.ttf"); - assert_eq!(font_file_name("Face.OTF".into(), b"ttcf"), "Face.OTF"); - assert_eq!(font_file_name("Face.dfont".into(), b"\0\0\x01\0"), "Face.dfont"); - assert_eq!(font_file_name("short".into(), b"OT"), "short"); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_uses_portable_names_inside_the_selected_directory() { - assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); - assert_eq!(safe_component(".", "Untitled"), "Untitled"); - assert_eq!(safe_component("CON", "Untitled"), "_CON"); - assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); - assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); - for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { - assert!(is_reserved_windows_name(name), "{name}"); - } - let mut taken = Vec::new(); - assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); - assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); - assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); - assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); - assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); - assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); - - let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); - let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - let files = r["files"].as_array().unwrap(); - assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); - assert!(dir.join("Annual_ Review.designcraft").is_file()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_overwrites_existing_regular_outputs() { - let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let document = dir.join("Annual.designcraft"); - std::fs::write(&document, b"previous package").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); - } - - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Annual"})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); - assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn package_accepts_a_relative_destination() { - let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Relative"})).unwrap(); - s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); - assert!(dir.join("Relative.designcraft").is_file()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[cfg(unix)] - #[test] - fn package_refuses_redirected_destinations() { - use std::os::unix::fs::symlink; - - let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base).unwrap(); - let outside = base.join("separate"); - std::fs::create_dir_all(&outside).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Annual"})).unwrap(); - - let redirected_root = base.join("redirected-package"); - symlink(&outside, &redirected_root).unwrap(); - assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); - assert!(!outside.join("Annual.designcraft").exists()); - std::fs::remove_file(&redirected_root).unwrap(); - - // A linked folder above the package is followed, as `/tmp` is on macOS. - let linked_parent = base.join("linked-parent"); - symlink(&outside, &linked_parent).unwrap(); - let nested_package = linked_parent.join("nested").join("Package"); - s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); - assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); - std::fs::remove_file(&linked_parent).unwrap(); - - let package = base.join("Package"); - std::fs::create_dir_all(&package).unwrap(); - symlink(&outside, package.join("Links")).unwrap(); - assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); - assert!(!outside.join("Annual.designcraft").exists()); - std::fs::remove_file(package.join("Links")).unwrap(); - std::fs::create_dir(package.join("Links")).unwrap(); - - let kept = outside.join("kept.designcraft"); - std::fs::write(&kept, b"kept").unwrap(); - symlink(&kept, package.join("Annual.designcraft")).unwrap(); - assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); - assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); - let _ = std::fs::remove_dir_all(&base); - } - - #[cfg(windows)] - #[test] - fn package_refuses_windows_directory_redirects() { - use std::os::windows::fs::symlink_dir; - - let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let outside = base.join("separate"); - std::fs::create_dir_all(&outside).unwrap(); - let redirected = base.join("Package"); - match symlink_dir(&outside, &redirected) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - let _ = std::fs::remove_dir_all(&base); - return; - } - Err(e) => panic!("could not create directory redirect: {e}"), - } - let mut s = Session::new(); - s.execute("file.new", &json!({"title": "Annual"})).unwrap(); - assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); - assert!(!outside.join("Annual.designcraft").exists()); - std::fs::remove_dir(&redirected).unwrap(); - let _ = std::fs::remove_dir_all(&base); - } -} +mod tests; diff --git a/crates/engine/src/cmd/package/tests.rs b/crates/engine/src/cmd/package/tests.rs new file mode 100644 index 000000000..acc6d657f --- /dev/null +++ b/crates/engine/src/cmd/package/tests.rs @@ -0,0 +1,476 @@ +use super::*; + +#[test] +fn relocated_packages_rebind_embedded_image_links_on_open() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-relocation-{}-{nonce}", std::process::id())); + let original = dir.join("original"); + let moved = dir.join("moved"); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "OwnedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "owned.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": original.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + std::fs::rename(&original, &moved).unwrap(); + let expected = std::path::absolute(moved.join("Links").join("owned.png")).unwrap(); + for (command, extension) in [("file.open", "designcraft"), ("file.openIdml", "idml")] { + let mut opened = Session::new(); + opened.execute(command, &json!({"path": moved.join(format!("OwnedPackage.{extension}")).to_string_lossy()})).unwrap(); + let asset = opened.doc().unwrap().doc.assets.values().next().unwrap(); + assert_eq!(*asset.data, png, "{extension}: the packaged pixels"); + assert_eq!(asset.link.as_deref(), expected.to_str(), "{extension}: use the relocated Links file"); + let links = opened.execute("links.list", &json!({})).unwrap(); + assert_eq!(links[0]["status"], "ok", "{extension}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the +/// report says the copies differ instead of leaving it to be discovered. +#[test] +fn package_report_names_placed_pdf_layer_choices() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-pdf-layers-{}-{nonce}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let pdf = super::super::pdflayers::tests::two_layer_pdf(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "LayeredPackage", "width": 100, "height": 100})).unwrap(); + let id = s.execute("file.place", &json!({"base64": super::super::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100})).unwrap() + ["id"] + .clone(); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + let report = r["report"].as_str().unwrap(); + assert!(report.contains("Placed PDF layers"), "{report}"); + // The DesignCraft copy keeps the choice; only the linked IDML copy shows file defaults. + let packed = super::super::from_bytes(&std::fs::read(dir.join("LayeredPackage.designcraft")).unwrap()).unwrap(); + let item = packed.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); + assert_eq!(item.pdf_hidden_layers, vec!["Red".to_string()]); + assert!(item.has_pdf_layer_override()); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The package's IDML links its images to the files in the package's Links folder and embeds +/// no copies of them. +#[test] +fn package_idml_links_the_links_folder_and_embeds_nothing() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("dc-package-idml-links-{}-{nonce}", std::process::id())); + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "LinkedPackage", "width": 72, "height": 72})).unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "my photo.png", "x": 5, "y": 5, "width": 16})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap(); + let idml = std::fs::read(dir.join("LinkedPackage.idml")).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(idml.as_slice())).unwrap(); + let mut spreads = String::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + if f.name().starts_with("Spreads/") { + std::io::Read::read_to_string(&mut f, &mut spreads).unwrap(); + } + } + // Relative to the package: no path from the machine that made it (#340). + let uri = "file:Links/my%20photo.png"; + let folder = dir.file_name().unwrap().to_string_lossy().into_owned(); + assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}"); + assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}"); + assert!(!spreads.contains(""##; + let (gone, kept) = (dir.join("gone.svg"), dir.join("kept.svg")); + std::fs::write(&gone, svg).unwrap(); + std::fs::write(&kept, svg.replace(""##).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.place", &json!({"path": svg.to_string_lossy(), "x": 100, "y": 100})).unwrap(); + s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Hello"})).unwrap(); + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "pdf": true, "instructions": "Print on matte."})).unwrap(); + let files: Vec = r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert!(files.iter().any(|f| f.ends_with(".designcraft"))); + assert!(files.iter().any(|f| f.ends_with(".idml"))); + assert!(files.iter().any(|f| f.ends_with(".pdf"))); + assert!(out.join("Links").join("logo.svg").exists()); + let report = std::fs::read_to_string(out.join("Instructions.txt")).unwrap(); + assert!(report.contains("Print on matte.") && report.contains("logo.svg"), "{report}"); + assert!(report.contains("Preflight:"), "{report}"); + // The packaged document links to its Links folder. + let doc_file = files.iter().find(|f| f.ends_with(".designcraft")).unwrap(); + let packed = super::super::from_bytes(&std::fs::read(doc_file).unwrap()).unwrap(); + let link = packed.assets.values().next().unwrap().link.clone().unwrap(); + assert!(link.contains("Links"), "{link}"); + // Copy Links To relinks the open document. + let r = s.execute("links.copyTo", &json!({"dir": dir.join("Copies").to_string_lossy()})).unwrap(); + assert_eq!(r["copied"], 1); + assert!(s.doc().unwrap().doc.assets.values().next().unwrap().link.as_deref().unwrap().contains("Copies")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_copies_the_fonts_their_licences_allow() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, with_fs_type}; + const FREE: &str = "DocFont Package Free"; + const RESTRICTED: &str = "DocFont Package Restricted"; + let dir = std::env::temp_dir().join(format!("dc-package-fonts-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("free.ttf"), font_with(FREE, &['F']).unwrap()).unwrap(); + std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &['R']).unwrap(), 0x0002).unwrap()).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(fonts.join("free.ttf"), std::fs::Permissions::from_mode(0o640)).unwrap(); + } + // Text in both fonts, and in the bundled default font. + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Free Restricted Default"})).unwrap(); + for (a, b, family) in [(0, 4, FREE), (5, 15, RESTRICTED)] { + s.execute("text.select", &json!({"story": r["story"], "anchor": a, "focus": b})).unwrap(); + s.execute("type.char", &json!({"fontFamily": family, "fontStyle": "Regular"})).unwrap(); + } + let src = dir.join("src").join("Fonts.designcraft"); + s.execute("file.save", &json!({"path": src.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 2); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); + let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); + assert!(packed_fonts.join("free.ttf").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!( + std::fs::metadata(packed_fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777, + std::fs::metadata(fonts.join("free.ttf")).unwrap().permissions().mode() & 0o777 + ); + } + assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps the font out"); + assert!(r["files"].to_string().contains("free.ttf"), "{}", r["files"]); + let report = r["report"].as_str().unwrap(); + assert!(report.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); + assert!(report.contains(&format!("{FREE} Regular\n")), "{report}"); + assert!(report.contains(&format!("{} Regular — included with DesignCraft", designcraft_fonts::DEFAULT_FAMILY)), "{report}"); + // The packaged document opens with its fonts. + let doc = r["files"][0].as_str().unwrap().to_string(); + assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 1); + // Packaging into the folder the fonts come from leaves them whole. + let len = std::fs::metadata(fonts.join("free.ttf")).unwrap().len(); + s.execute("file.package", &json!({"dir": dir.join("src").to_string_lossy(), "idml": false})).unwrap(); + assert_eq!(std::fs::metadata(fonts.join("free.ttf")).unwrap().len(), len); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_copies_the_fallback_fonts_that_draw_text() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, with_fs_type}; + const NAMED: &str = "DocFont Fallback Named"; + const HELPER: &str = "DocFont Fallback Helper"; + const RESTRICTED: &str = "DocFont Fallback Restricted"; + // Characters no other font has: the named font lacks both, the document's other fonts + // draw them. + let (helped, restricted) = ('\u{F0A41}', '\u{F0A42}'); + let dir = std::env::temp_dir().join(format!("dc-package-fallback-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let fonts = dir.join("src").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + std::fs::write(fonts.join("named.ttf"), font_with(NAMED, &['N']).unwrap()).unwrap(); + std::fs::write(fonts.join("helper.ttf"), font_with(HELPER, &[helped]).unwrap()).unwrap(); + std::fs::write(fonts.join("restricted.otf"), with_fs_type(font_with(RESTRICTED, &[restricted]).unwrap(), 0x0002).unwrap()).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("document.preferences", &json!({"glyphFallback": true})).unwrap(); + let text = format!("N{helped}{restricted}"); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap(); + s.execute("type.char", &json!({"fontFamily": NAMED, "fontStyle": "Regular"})).unwrap(); + let src = dir.join("src").join("Fallback.designcraft"); + // Written without File › Save's preview: drawing it before the document's fonts load would + // pull in a system font that covers these characters (Unifont CSUR does) and draws them + // ahead of the document's own fonts in this process. + std::fs::write(&src, crate::cmd::to_bytes(&s.doc().unwrap().doc).unwrap()).unwrap(); + let mut s = Session::new(); + assert_eq!(s.execute("file.open", &json!({"path": src.to_string_lossy()})).unwrap()["documentFonts"], 3); + + let out = dir.join("Pkg"); + let r = s.execute("file.package", &json!({"dir": out.to_string_lossy(), "idml": false})).unwrap(); + let packed_fonts = out.join(DOCUMENT_FONTS_FOLDER); + assert!(packed_fonts.join("named.ttf").exists()); + assert!(packed_fonts.join("helper.ttf").exists(), "the fallback font that draws text is copied"); + assert!(!packed_fonts.join("restricted.otf").exists(), "a restricted licence keeps a fallback font out too"); + let report = r["report"].as_str().unwrap(); + let fallback = report.split("Fallback fonts").nth(1).unwrap_or_else(|| panic!("{report}")); + assert!(fallback.contains(&format!("{HELPER} Regular\n")), "{report}"); + assert!(fallback.contains(&format!("{RESTRICTED} Regular — not copied: its licence doesn't allow it")), "{report}"); + assert!(!fallback.contains(NAMED), "{report}"); + // The packaged document draws the same text with its fonts. + let doc = r["files"][0].as_str().unwrap().to_string(); + assert_eq!(Session::new().execute("file.open", &json!({"path": doc})).unwrap()["documentFonts"], 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn packaged_fonts_keep_a_font_extension() { + use designcraft_fonts::DOCUMENT_FONTS_FOLDER; + use designcraft_fonts::testing::{font_with, installed_face}; + // A font service's file without an extension is copied under a name the reopened + // package loads. + let dir = std::env::temp_dir().join(format!("dc-package-font-ext-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let from = dir.join(".12345"); + let bytes = font_with("DocFont Package Extensionless", &['E']).unwrap(); + std::fs::write(&from, &bytes).unwrap(); + let face = installed_face(bytes, from).unwrap(); + let root = OutputRoot::new(dir.clone()).unwrap(); + let fonts = dir.join("Pkg").join(DOCUMENT_FONTS_FOLDER); + std::fs::create_dir_all(&fonts).unwrap(); + let (mut copied, mut taken, mut files) = (Vec::new(), Vec::new(), Vec::new()); + assert_eq!(copy_font(&root, &face, &fonts, &mut copied, &mut taken, &mut files), ""); + assert_eq!(files, [fonts.join("12345.ttf")]); + let db = designcraft_fonts::FontDb::global(); + let loaded = db.load_document_fonts(&fonts, &|_| Ok(())); + assert_eq!((loaded.faces, loaded.skipped.len()), (1, 0), "{:?}", loaded.skipped); + db.close_scope(loaded.scope); + // The first four bytes name the kind; a font extension or an unknown kind stays. + assert_eq!(font_file_name("Face".into(), b"OTTO...."), "Face.otf"); + assert_eq!(font_file_name("Faces".into(), b"ttcf...."), "Faces.ttc"); + assert_eq!(font_file_name("Mac".into(), b"true...."), "Mac.ttf"); + assert_eq!(font_file_name("..".into(), &[0, 1, 0, 0]), "font.ttf"); + assert_eq!(font_file_name("Face.OTF".into(), b"ttcf"), "Face.OTF"); + assert_eq!(font_file_name("Face.dfont".into(), b"\0\0\x01\0"), "Face.dfont"); + assert_eq!(font_file_name("short".into(), b"OT"), "short"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_uses_portable_names_inside_the_selected_directory() { + assert_eq!(safe_component("Archive/Annual: Review", "Untitled"), "Annual_ Review"); + assert_eq!(safe_component(".", "Untitled"), "Untitled"); + assert_eq!(safe_component("CON", "Untitled"), "_CON"); + assert_eq!(safe_component("COM¹.txt", "Untitled"), "_COM¹.txt"); + assert_eq!(safe_component("LPT³", "Untitled"), "_LPT³"); + for name in ["COM¹", "COM²", "COM³", "LPT¹", "LPT²", "LPT³"] { + assert!(is_reserved_windows_name(name), "{name}"); + } + let mut taken = Vec::new(); + assert_eq!(unique_name("Logo.svg".into(), &mut taken), "Logo.svg"); + assert_eq!(unique_name("logo.svg".into(), &mut taken), "logo 2.svg"); + assert_eq!(unique_name("Résumé.pdf".into(), &mut taken), "Résumé.pdf"); + assert_eq!(unique_name("Re\u{301}sume\u{301}.pdf".into(), &mut taken), "Re\u{301}sume\u{301} 2.pdf"); + assert_eq!(unique_name("ΟΣ.txt".into(), &mut taken), "ΟΣ.txt"); + assert_eq!(unique_name("ος.txt".into(), &mut taken), "ος 2.txt"); + + let dir = std::env::temp_dir().join(format!("dc-package-safe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Archive/Annual: Review"})).unwrap(); + let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert!(files.iter().all(|path| Path::new(path.as_str().unwrap()).starts_with(&dir))); + assert!(dir.join("Annual_ Review.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_overwrites_existing_regular_outputs() { + let dir = std::env::temp_dir().join(format!("dc-package-overwrite-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let document = dir.join("Annual.designcraft"); + std::fs::write(&document, b"previous package").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(&document, std::fs::Permissions::from_mode(0o640)).unwrap(); + } + + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(super::super::from_bytes(&std::fs::read(&document).unwrap()).is_ok()); + assert!(!dir.join(designcraft_fonts::DOCUMENT_FONTS_FOLDER).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + assert_eq!(std::fs::metadata(&document).unwrap().permissions().mode() & 0o777, 0o640); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn package_accepts_a_relative_destination() { + let dir = PathBuf::from(format!(".dc-package-relative-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Relative"})).unwrap(); + s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": false})).unwrap(); + assert!(dir.join("Relative.designcraft").is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[cfg(unix)] +#[test] +fn package_refuses_redirected_destinations() { + use std::os::unix::fs::symlink; + + let base = std::env::temp_dir().join(format!("dc-package-redirects-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + + let redirected_root = base.join("redirected-package"); + symlink(&outside, &redirected_root).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected_root.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(&redirected_root).unwrap(); + + // A linked folder above the package is followed, as `/tmp` is on macOS. + let linked_parent = base.join("linked-parent"); + symlink(&outside, &linked_parent).unwrap(); + let nested_package = linked_parent.join("nested").join("Package"); + s.execute("file.package", &json!({"dir": nested_package.to_string_lossy(), "idml": false})).unwrap(); + assert!(outside.join("nested").join("Package").join("Annual.designcraft").is_file()); + std::fs::remove_file(&linked_parent).unwrap(); + + let package = base.join("Package"); + std::fs::create_dir_all(&package).unwrap(); + symlink(&outside, package.join("Links")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_file(package.join("Links")).unwrap(); + std::fs::create_dir(package.join("Links")).unwrap(); + + let kept = outside.join("kept.designcraft"); + std::fs::write(&kept, b"kept").unwrap(); + symlink(&kept, package.join("Annual.designcraft")).unwrap(); + assert!(s.execute("file.package", &json!({"dir": package.to_string_lossy(), "idml": false})).is_err()); + assert_eq!(std::fs::read(&kept).unwrap(), b"kept"); + let _ = std::fs::remove_dir_all(&base); +} + +#[cfg(windows)] +#[test] +fn package_refuses_windows_directory_redirects() { + use std::os::windows::fs::symlink_dir; + + let base = std::env::temp_dir().join(format!("dc-package-windows-redirect-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let outside = base.join("separate"); + std::fs::create_dir_all(&outside).unwrap(); + let redirected = base.join("Package"); + // A symbolic link needs Developer Mode or elevation (os error 1314 without them); a + // directory junction needs neither, and `is_redirect` treats both as redirects. + if symlink_dir(&outside, &redirected).is_err() && !make_junction(&outside, &redirected) { + eprintln!("skipped: directory redirects can't be made here"); + let _ = std::fs::remove_dir_all(&base); + return; + } + let mut s = Session::new(); + s.execute("file.new", &json!({"title": "Annual"})).unwrap(); + assert!(s.execute("file.package", &json!({"dir": redirected.to_string_lossy(), "idml": false})).is_err()); + assert!(!outside.join("Annual.designcraft").exists()); + std::fs::remove_dir(&redirected).unwrap(); + let _ = std::fs::remove_dir_all(&base); +} + +/// Makes `link` a directory junction to `target` with `mklink /J`, and reports whether it was made. +#[cfg(windows)] +fn make_junction(target: &Path, link: &Path) -> bool { + use std::os::windows::process::CommandExt; + + // Windows paths can't hold `"`, so quoting each one keeps `&` and spaces in it literal for cmd. + std::process::Command::new("cmd") + .args(["/D", "/C"]) + .raw_arg(format!("mklink /J \"{}\" \"{}\"", link.display(), target.display())) + .output() + .is_ok_and(|output| output.status.success()) +} diff --git a/crates/engine/src/cmd/paste_svg.rs b/crates/engine/src/cmd/paste_svg.rs index 54513be53..a5d738cef 100644 --- a/crates/engine/src/cmd/paste_svg.rs +++ b/crates/engine/src/cmd/paste_svg.rs @@ -8,7 +8,7 @@ use designcraft_geom::{Affine, BezPath, PathData, Point, Rect}; use designcraft_images::usvg; use serde_json::{Value, json}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, spread_param, str_param}; pub fn specs() -> Vec { vec![cmd!( @@ -27,7 +27,7 @@ pub fn is_svg_markup(text: &str) -> bool { designcraft_images::is_svg(text.as_bytes()) } -/// Nesting deeper than this is flattened into its parent (hostile input can't exhaust the stack). +/// Cap conversion nesting too; the shared image parser bounds XML before usvg runs. const MAX_DEPTH: usize = 64; /// A converted SVG node, in points. @@ -426,13 +426,16 @@ mod tests { #[test] fn deeply_nested_groups_do_not_exhaust_the_stack() { let mut s = session(); + let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); let depth = 2000; let svg = format!( r#"{}{}"#, "".repeat(depth), "".repeat(depth) ); - // usvg itself may refuse a document this deep; either way, no crash. - let _ = s.execute("edit.pasteSvg", &json!({"svg": svg})); + assert!(s.execute("edit.pasteSvg", &json!({"svg": svg})).is_err()); + assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); } } diff --git a/crates/engine/src/cmd/pdflayers.rs b/crates/engine/src/cmd/pdflayers.rs index 937fe81c0..75e573b56 100644 --- a/crates/engine/src/cmd/pdflayers.rs +++ b/crates/engine/src/cmd/pdflayers.rs @@ -15,13 +15,15 @@ fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option Vec { vec![ cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| { - let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; + let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.iter().copied().find(|id| placed_pdf(s, *id).is_some()))).ok_or_else(|| bad("object.pdfLayers", "no selection"))?; let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?; - let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + let item = s.doc()?.doc.item(id); + let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default(); + let overridden = item.is_some_and(|i| i.has_pdf_layer_override()); Ok(Value::Array( designcraft_render::pdf_layers(&data) .into_iter() - .map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on})) + .map(|(name, on)| json!({"visible": if overridden { !hidden.contains(&name) } else { on }, "name": name, "default": on, "overridden": overridden})) .collect(), )) }), @@ -30,30 +32,39 @@ pub fn specs() -> Vec { "Object Layer Options…", ["Object"], None, - "{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)", + "{ids?, hidden: [layer name], useDefaults?: bool} — hide layers; useDefaults:false makes [] explicitly all on; legacy [] restores file defaults", has_selection, |s, p| { - let ids = targets(s, p)?; + let ids: Vec<_> = targets(s, p)?.into_iter().filter(|id| placed_pdf(s, *id).is_some()).collect(); let hidden: Vec = p .get("hidden") .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default(); - if ids.iter().all(|id| placed_pdf(s, *id).is_none()) { + let use_defaults = match p.get("useDefaults") { + None => hidden.is_empty(), + Some(v) => v.as_bool().ok_or_else(|| bad("object.layerOptions", "useDefaults must be a boolean"))?, + }; + if use_defaults && !hidden.is_empty() { + return Err(bad("object.layerOptions", "useDefaults cannot be combined with hidden layers")); + } + if ids.is_empty() { return Err(bad("object.layerOptions", "select a placed PDF")); } - // Validate every selected PDF before editing any item or recording undo. - // Repeated placements of the same source need only one rewrite check. - if !hidden.is_empty() { + // Validate every selected PDF before editing any item or recording undo, so a + // mixed request stays atomic. Any explicit choice, including all-on + // (useDefaults:false with no hidden names), needs one actual rewrite per distinct + // source: a PDF can list readable layers and still fail the rewrite, and a + // layerless PDF cannot be rewritten at all. Resetting to the file defaults needs no + // variant and is always allowed. + if !use_defaults { let mut checked = std::collections::HashSet::new(); for id in &ids { - if let Some(data) = placed_pdf(s, *id) - && checked.insert(std::sync::Arc::as_ptr(&data)) - && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() - { + let Some(data) = placed_pdf(s, *id) else { continue }; + if checked.insert(std::sync::Arc::as_ptr(&data)) && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() { return Err(bad( "object.layerOptions", - "the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF", + "the selected PDF layers could not be changed (no readable optional-content layers, or an unsupported file); use the file defaults or replace the PDF", )); } } @@ -62,9 +73,10 @@ pub fn specs() -> Vec { for id in &ids { if let Some(it) = d.item_mut(*id) { it.pdf_hidden_layers = hidden.clone(); + it.pdf_layer_override = !use_defaults; } } - Ok(json!({"hidden": hidden})) + Ok(json!({"hidden": hidden, "useDefaults": use_defaults})) }) } ), @@ -72,310 +84,4 @@ pub fn specs() -> Vec { } #[cfg(test)] -pub(crate) mod tests { - use serde_json::json; - - use crate::Session; - - /// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). - pub(crate) fn two_layer_pdf() -> Vec { - two_layer_pdf_generation(0) - } - - fn two_layer_pdf_generation(generation: i32) -> Vec { - two_layer_pdf_options(generation, "", "") - } - - fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec { - let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; - let objs = [ - format!( - "<>>>>>" - ), - "<>".to_string(), - "<>>>/Contents 4 0 R>>".to_string(), - format!("<>\nstream\n{content}\nendstream", content.len()), - "<>".to_string(), - "<>".to_string(), - "<>\nstream\n\nendstream".to_string(), - "<>".to_string(), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes()); - } - let x = out.len(); - let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); - for o in offs { - t.push_str(&format!("{o:010} {generation:05} n\r\n")); - } - t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); - out.extend_from_slice(t.as_bytes()); - out - } - - #[test] - fn pdf_layer_update_preserves_catalog_configuration_and_generations() { - for generation in [0, 1] { - let original = two_layer_pdf_generation(generation); - let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap(); - assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); - let appended = std::str::from_utf8(&updated[original.len()..]).unwrap(); - for token in [ - "/Lang(en-GB)".to_string(), - format!("/Metadata 7 {generation} R"), - format!("/OutputIntents[8 {generation} R]"), - format!("/Locked[5 {generation} R]"), - "/Name(Default)".into(), - format!("/Root 1 {generation} R"), - ] { - assert!(appended.contains(&token), "missing {token}: {appended}"); - } - assert!(!appended.contains("9999000")); - let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap(); - assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible)); - } - } - - #[test] - fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() { - let original = two_layer_pdf(); - let text = std::str::from_utf8(&original).unwrap(); - let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); - let mut stream_pdf = original[..offset].to_vec(); - let mut entries = vec![0, 0, 0, 0, 0, 255, 255]; - for number in 1..=9 { - let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() }; - entries.push(1); - entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes()); - entries.extend_from_slice(&[0, 0]); - } - stream_pdf.extend_from_slice(format!("9 0 obj\n<>\nstream\n", entries.len()).as_bytes()); - stream_pdf.extend_from_slice(&entries); - stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); - let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap(); - assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); - for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] { - let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9")); - assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none()); - } - } - - #[test] - fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() { - let original = two_layer_pdf(); - let text = std::str::from_utf8(&original).unwrap(); - let first = original.len(); - let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<>\n"); - let main = first + link(0).len(); - let mut forward = original.clone(); - forward.extend_from_slice(link(main).as_bytes()); - let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); - forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes()); - forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); - let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap(); - assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); - let mut cyclic = original; - cyclic.extend_from_slice(link(first).as_bytes()); - cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); - assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none()); - } - - fn add_pdf_revision(bytes: &mut Vec) { - let text = std::str::from_utf8(bytes).unwrap(); - let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); - let xref = bytes.len(); - bytes.extend_from_slice( - format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), - ); - } - - fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value { - s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100})) - .unwrap()["id"] - .clone() - } - - fn pixel(s: &Session, x: u32) -> [u8; 4] { - let mut renderer = designcraft_render::Renderer::new(); - renderer.threads = 0; - renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50) - } - - #[test] - fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() { - for generation in [0, 1] { - let bytes = two_layer_pdf_options(generation, "", "/Encrypt null"); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); - let id = place(&mut s, &bytes, 0.0); - s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); - assert_eq!(pixel(&s, 25), [0, 0, 255, 255]); - assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); - s.execute("file.exportPdf", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); - } - } - - #[test] - fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() { - let mut unsupported = two_layer_pdf(); - for _ in 0..32 { - add_pdf_revision(&mut unsupported); - } - assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); - assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none()); - let mut s = Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); - let good = place(&mut s, &two_layer_pdf(), 0.0); - let bad = place(&mut s, &unsupported, 100.0); - s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(!s.doc().unwrap().history.redo.is_empty()); - let before = s.execute("file.serialize", &json!({})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let redo = s.doc().unwrap().history.redo.len(); - for ids in [json!([good, bad]), json!([bad, good])] { - let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); - assert!(err.to_string().contains("could not be hidden")); - assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - assert_eq!(s.doc().unwrap().history.redo.len(), redo); - for id in [&good, &bad] { - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true); - } - } - assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); - assert_eq!(pixel(&s, 175), [255, 0, 0, 255]); - // A persisted/relinked choice can be unrewritable. Reset must still work. - let bad_id = serde_json::from_value(bad.clone()).unwrap(); - std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()]; - assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas"); - assert!(s.execute("file.exportPdf", &json!({})).is_err()); - s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap(); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true); - s.execute("file.exportPdf", &json!({})).unwrap(); - } - - #[test] - fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { - for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { - let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}")); - assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}"); - assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}"); - } - for trailer in ["", "/Prev null"] { - let bytes = two_layer_pdf_options(0, "", trailer); - assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}"); - } - let mut revised = two_layer_pdf(); - let prev = - std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::().unwrap(); - add_pdf_revision(&mut revised); - let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>")); - assert!(real_prev.contains(&format!("/Prev {prev}.0>>"))); - for bytes in [revised, real_prev.into_bytes()] { - assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work"); - } - } - - #[test] - fn fake_null_trailer_cannot_bypass_hybrid_rejection() { - for real_guard in ["", "/XRefStm 0"] { - let bytes = two_layer_pdf_options(0, "", real_guard); - let text = std::str::from_utf8(&bytes).unwrap(); - for fake in ["/Encrypt null", ""] { - let comment = format!("% trailer <>\n"); - let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1); - assert!(inside.contains(&format!("xref\n{comment}"))); - // Repair-only comments inside a table are always refused, guard or not. - assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2); - assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none()); - // Before xref or after the dictionary, the real trailer decides. - let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1); - let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1); - for source in [leading, trailing] { - assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2); - assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty()); - } - } - } - } - - #[test] - fn content_all_still_measures_layerless_and_stub_properties() { - use designcraft_doc::PdfCrop; - for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<>>>"] { - let content = "0 0 1 rg 0 0 50 100 re f"; - let objs = [ - format!("<>"), - "<>".into(), - "<>".into(), - format!("<>\nstream\n{content}\nendstream", content.len()), - ]; - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, body) in objs.iter().enumerate() { - offsets.push(bytes.len()); - bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); - for at in offsets { - bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes()); - } - bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - assert!(designcraft_render::pdf_layers(&bytes).is_empty()); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}"); - } - } - - #[test] - fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() { - use designcraft_doc::PdfCrop; - let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", ""); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0)); - for _ in 0..32 { - add_pdf_revision(&mut bytes); - } - assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none()); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); - assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0))); - } - - #[test] - fn placed_pdf_layers_hide() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let pdf = two_layer_pdf(); - let r = s - .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 100, "y": 100, "width": 100})) - .unwrap(); - let id = r["id"].clone(); - let l = s.execute("object.pdfLayers", &json!({"id": id})).unwrap(); - assert_eq!(l.as_array().unwrap().len(), 2); - assert_eq!(l[0]["name"], "Blue"); - let px = |s: &Session, x: u32| { - let d = s.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 150) - }; - assert!(px(&s, 125)[2] > 200 && px(&s, 175)[0] > 200, "both layers show"); - s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); - let right = px(&s, 175); - assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); - assert!(px(&s, 125)[2] > 200, "blue still shows"); - assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); - // PDF export carries the choice (the graphic goes out as an image). - let r = s.execute("file.exportPdf", &json!({})).unwrap(); - assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("layer")), "{r}"); - } -} +pub(crate) mod tests; diff --git a/crates/engine/src/cmd/pdflayers/tests.rs b/crates/engine/src/cmd/pdflayers/tests.rs new file mode 100644 index 000000000..8612303e2 --- /dev/null +++ b/crates/engine/src/cmd/pdflayers/tests.rs @@ -0,0 +1,540 @@ +use serde_json::json; + +use crate::Session; + +/// A one-page PDF (100×100 pt) with two layers: "Blue" (left half) and "Red" (right half). +pub(crate) fn two_layer_pdf() -> Vec { + two_layer_pdf_generation(0) +} + +fn two_layer_pdf_generation(generation: i32) -> Vec { + two_layer_pdf_options(generation, "", "") +} + +fn two_layer_pdf_options(generation: i32, config: &str, trailer: &str) -> Vec { + let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC"; + let objs = [ + format!( + "<>>>>>" + ), + "<>".to_string(), + "<>>>/Contents 4 0 R>>".to_string(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>".to_string(), + "<>".to_string(), + "<>\nstream\n\nendstream".to_string(), + "<>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} {generation} obj\n{}\nendobj\n", i + 1, o.replace(" 0 R", &format!(" {generation} R"))).as_bytes()); + } + let x = out.len(); + let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1); + for o in offs { + t.push_str(&format!("{o:010} {generation:05} n\r\n")); + } + t.push_str(&format!("trailer\n<>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1)); + out.extend_from_slice(t.as_bytes()); + out +} + +#[test] +fn pdf_layer_update_preserves_catalog_configuration_and_generations() { + for generation in [0, 1] { + let original = two_layer_pdf_generation(generation); + let updated = designcraft_render::pdf_hide_layers(&original, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let appended = std::str::from_utf8(&updated[original.len()..]).unwrap(); + for token in [ + "/Lang(en-GB)".to_string(), + format!("/Metadata 7 {generation} R"), + format!("/OutputIntents[8 {generation} R]"), + format!("/Locked[5 {generation} R]"), + "/Name(Default)".into(), + format!("/Root 1 {generation} R"), + ] { + assert!(appended.contains(&token), "missing {token}: {appended}"); + } + assert!(!appended.contains("9999000")); + let all_on = designcraft_render::pdf_hide_layers(&updated, &[]).unwrap(); + assert!(designcraft_render::pdf_layers(&all_on).iter().all(|(_, visible)| *visible)); + } +} + +#[test] +fn pdf_layer_update_accepts_xref_streams_and_rejects_unsupported_updates() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let offset: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let mut stream_pdf = original[..offset].to_vec(); + let mut entries = vec![0, 0, 0, 0, 0, 255, 255]; + for number in 1..=9 { + let at = if number == 9 { offset } else { text.find(&format!("{number} 0 obj")).unwrap() }; + entries.push(1); + entries.extend_from_slice(&u32::try_from(at).unwrap().to_be_bytes()); + entries.extend_from_slice(&[0, 0]); + } + stream_pdf.extend_from_slice(format!("9 0 obj\n<>\nstream\n", entries.len()).as_bytes()); + stream_pdf.extend_from_slice(&entries); + stream_pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&stream_pdf, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + for unsupported in ["/Encrypt 7 0 R", "/Encrypt << /Filter /Standard >>", "/XRefStm 0"] { + let altered = text.replace("/Size 9", &format!("{unsupported}/Size 9")); + assert!(designcraft_render::pdf_hide_layers(altered.as_bytes(), &["Red".into()]).is_none()); + } +} + +#[test] +fn pdf_layer_update_follows_forward_prev_and_rejects_cycles() { + let original = two_layer_pdf(); + let text = std::str::from_utf8(&original).unwrap(); + let first = original.len(); + let link = |prev: usize| format!("xref\n0 1\n0000000000 65535 f\ntrailer<>\n"); + let main = first + link(0).len(); + let mut forward = original.clone(); + forward.extend_from_slice(link(main).as_bytes()); + let old: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + forward.extend_from_slice(text[old..].split("startxref").next().unwrap().as_bytes()); + forward.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + let updated = designcraft_render::pdf_hide_layers(&forward, &["Red".into()]).unwrap(); + assert_eq!(designcraft_render::pdf_layers(&updated), vec![("Blue".into(), true), ("Red".into(), false)]); + let mut cyclic = original; + cyclic.extend_from_slice(link(first).as_bytes()); + cyclic.extend_from_slice(format!("startxref\n{first}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_hide_layers(&cyclic, &[]).is_none()); +} + +fn add_pdf_revision(bytes: &mut Vec) { + let text = std::str::from_utf8(bytes).unwrap(); + let prev: usize = text.rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse().unwrap(); + let xref = bytes.len(); + bytes.extend_from_slice( + format!("xref\n0 1\n0000000000 65535 f\r\ntrailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); +} + +fn place(s: &mut Session, bytes: &[u8], x: f64) -> serde_json::Value { + s.execute("file.place", &json!({"base64": super::super::file::base64_encode(bytes), "name": "layers.pdf", "x": x, "y": 0, "width": 100})).unwrap() + ["id"] + .clone() +} + +fn pixel(s: &Session, x: u32) -> [u8; 4] { + let mut renderer = designcraft_render::Renderer::new(); + renderer.threads = 0; + renderer.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 50) +} + +#[test] +fn null_encryption_entry_keeps_layer_editing_rendering_and_export_working() { + for generation in [0, 1] { + let bytes = two_layer_pdf_options(generation, "", "/Encrypt null"); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let id = place(&mut s, &bytes, 0.0); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); + assert_eq!(pixel(&s, 25), [0, 0, 255, 255]); + assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); + s.execute("file.exportPdf", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(pixel(&s, 75), [255, 255, 255, 255]); + } +} + +#[test] +fn unrewritable_layer_choices_are_rejected_atomically_without_an_undo_step() { + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &["Red".into()]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": ["Blue"]})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().history.redo.is_empty()); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let redo = s.doc().unwrap().history.redo.len(); + for ids in [json!([good, bad]), json!([bad, good])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err(); + assert!(err.to_string().contains("could not be changed")); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.doc().unwrap().history.redo.len(), redo); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], true); + } + } + assert_eq!(pixel(&s, 75), [255, 0, 0, 255]); + assert_eq!(pixel(&s, 175), [255, 0, 0, 255]); + // A persisted/relinked choice can be unrewritable. Reset must still work. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_hidden_layers = vec!["Red".into()]; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "stale hidden content must not reappear on canvas"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); + s.execute("object.layerOptions", &json!({"ids": [good, bad], "hidden": []})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": bad})).unwrap()[1]["visible"], true); + s.execute("file.exportPdf", &json!({})).unwrap(); +} + +#[test] +fn explicit_all_on_validates_an_actual_rewrite_before_editing() { + // 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history. + let mut unsupported = two_layer_pdf(); + for _ in 0..32 { + add_pdf_revision(&mut unsupported); + } + assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2); + assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap(); + let good = place(&mut s, &two_layer_pdf(), 0.0); + let bad = place(&mut s, &unsupported, 100.0); + let before = s.execute("file.serialize", &json!({})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] { + let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err(); + assert!(err.to_string().contains("could not be changed"), "{err}"); + assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults"); + s.execute("file.exportPdf", &json!({})).unwrap(); + // The supported source alone still accepts the explicit all-on choice. + s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true); + // Resetting to file defaults needs no variant, so it bypasses the rewrite check. + for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] { + s.execute("object.layerOptions", &reset).unwrap(); + for id in [&good, &bad] { + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false); + } + } + // A stale explicit all-on choice that outlived its rewritable source still fails closed. + let bad_id = serde_json::from_value(bad.clone()).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true; + assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source"); + assert!(s.execute("file.exportPdf", &json!({})).is_err()); +} + +#[test] +fn unreadable_non_null_prev_entries_refuse_layer_rewrites() { + for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] { + let bytes = two_layer_pdf_options(0, "", &format!("/Prev {prev}")); + assert_eq!(designcraft_render::pdf_layers(&bytes).len(), 2, "reader control: {prev}"); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_none(), "must not truncate unknown history: {prev}"); + } + for trailer in ["", "/Prev null"] { + let bytes = two_layer_pdf_options(0, "", trailer); + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "end-of-chain control: {trailer}"); + } + let mut revised = two_layer_pdf(); + let prev = + std::str::from_utf8(&revised).unwrap().rsplit_once("startxref").unwrap().1.split_whitespace().next().unwrap().parse::().unwrap(); + add_pdf_revision(&mut revised); + let real_prev = std::str::from_utf8(&revised).unwrap().replace(&format!("/Prev {prev}>>"), &format!("/Prev {prev}.0>>")); + assert!(real_prev.contains(&format!("/Prev {prev}.0>>"))); + for bytes in [revised, real_prev.into_bytes()] { + assert!(designcraft_render::pdf_hide_layers(&bytes, &["Red".into()]).is_some(), "readable integer/real history must still work"); + } +} + +#[test] +fn fake_null_trailer_cannot_bypass_hybrid_rejection() { + for real_guard in ["", "/XRefStm 0"] { + let bytes = two_layer_pdf_options(0, "", real_guard); + let text = std::str::from_utf8(&bytes).unwrap(); + for fake in ["/Encrypt null", ""] { + let comment = format!("% trailer <>\n"); + let inside = text.replacen("xref\n", &format!("xref\n{comment}"), 1); + assert!(inside.contains(&format!("xref\n{comment}"))); + // Repair-only comments inside a table are always refused, guard or not. + assert_eq!(designcraft_render::pdf_layers(inside.as_bytes()).len(), 2); + assert!(designcraft_render::pdf_hide_layers(inside.as_bytes(), &["Red".into()]).is_none()); + // Before xref or after the dictionary, the real trailer decides. + let leading = text.replacen("xref\n", &format!("{comment}xref\n"), 1); + let trailing = text.replacen("startxref\n", &format!("{comment}startxref\n"), 1); + for source in [leading, trailing] { + assert_eq!(designcraft_render::pdf_layers(source.as_bytes()).len(), 2); + assert_eq!(designcraft_render::pdf_hide_layers(source.as_bytes(), &["Red".into()]).is_some(), real_guard.is_empty()); + } + } + } +} + +#[test] +fn content_all_still_measures_layerless_and_stub_properties() { + use designcraft_doc::PdfCrop; + for props in ["", "/OCProperties null", "/OCProperties<<>>", "/OCProperties<>>>"] { + let content = "0 0 1 rg 0 0 50 100 re f"; + let objs = [ + format!("<>"), + "<>".into(), + "<>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f\r\n"); + for at in offsets { + bytes.extend_from_slice(format!("{at:010} 00000 n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + assert!(designcraft_render::pdf_layers(&bytes).is_empty()); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (50.0, 100.0), "{props}"); + } +} + +#[test] +fn content_all_keeps_the_whole_page_when_layer_rewriting_is_unsupported() { + use designcraft_doc::PdfCrop; + let mut bytes = two_layer_pdf_options(0, "/BaseState/OFF/ON[5 0 R]", ""); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap().1, (100.0, 100.0)); + for _ in 0..32 { + add_pdf_revision(&mut bytes); + } + assert!(designcraft_render::pdf_hide_layers(&bytes, &[]).is_none()); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentVisible).unwrap().1, (50.0, 100.0)); + assert_eq!(designcraft_render::pdf_crop_box(&bytes, 0, PdfCrop::ContentAll).unwrap(), ([0.0, 0.0, 1.0, 1.0], (100.0, 100.0))); +} + +#[test] +fn placed_pdf_layers_hide() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = two_layer_pdf(); + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "art.pdf", "x": 100, "y": 100, "width": 100})) + .unwrap(); + let id = r["id"].clone(); + let l = s.execute("object.pdfLayers", &json!({"id": id})).unwrap(); + assert_eq!(l.as_array().unwrap().len(), 2); + assert_eq!(l[0]["name"], "Blue"); + let px = |s: &Session, x: u32| { + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(x, 150) + }; + assert!(px(&s, 125)[2] > 200 && px(&s, 175)[0] > 200, "both layers show"); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap(); + let right = px(&s, 175); + assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "the red layer is hidden: {right:?}"); + assert!(px(&s, 125)[2] > 200, "blue still shows"); + assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"], false); + let r = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap()); + let image = designcraft_render::render_pdf_page(&bytes, 0, 792).unwrap(); + let width = usize::from(image.width()); + assert!(image.data()[150 * width + 125].a > 200); + assert_eq!(image.data()[150 * width + 175].a, 0); +} +#[test] +fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() { + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100})) + .unwrap()["id"] + .clone(); + let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap(); + let pixel = |s: &Session| { + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150) + }; + assert!(!visible(&mut s)); + assert!(pixel(&s)[1] > 200, "red hidden by default"); + s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap(); + assert!(visible(&mut s)); + assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!visible(&mut s)); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(visible(&mut s)); + let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap(); + let roundtrip = designcraft_format::load(&bytes).unwrap(); + let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap(); + assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty()); + let exported = s.execute("file.exportPdf", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap()); + let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap(); + let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap(); + assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}"); + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap()); + let imported = designcraft_idml::import_idml(&bytes).unwrap(); + let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); + assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on)); + assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err()); + assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap(); + assert!(!visible(&mut s), "legacy empty list still resets defaults"); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap(); + s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap(); + assert!(!visible(&mut s), "explicit reset restores defaults"); +} +#[test] +fn parent_page_choices_reach_idml_variants_and_raster_export() { + // Red is hidden by the file itself, so "show every layer" is a visible difference. + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap(); + let id = s + .execute( + "file.place", + &json!({"base64": super::super::file::base64_encode(&pdf), "name": "on-parent.pdf", "spread": {"kind": "parent", "index": 0}, "x": 0.0, "y": 0.0, "width": 100}), + ) + .unwrap()["id"] + .clone(); + let item = designcraft_doc::ItemId(id.as_u64().unwrap()); + assert!(matches!(s.doc().unwrap().doc.find(item).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)), "the placement sits on a parent page"); + s.execute("object.layerOptions", &json!({"ids": [id], "hidden": [], "useDefaults": false})).unwrap(); + // Embedded IDML: the parent-page placement gets its own variant, like a document one. + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap(); + let asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap(); + assert!(designcraft_render::pdf_layers(&asset.data).iter().all(|(_, on)| *on), "the embedded copy shows every layer"); + // An external-only copy would keep the original file's defaults: the choice is rejected. + let err = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string(); + assert!(err.contains("embedded images"), "{err}"); + // PDF/A-2b rasterises a parent-page placement with a choice, warnings included. + let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}"); +} + +/// An object anchored in a story is a placement too: the IDML copy gets a variant that shows +/// every layer, an external-only copy is refused, and PDF/A-2b rasterises it, rather than each +/// quietly falling back to the file's defaults. +#[test] +fn an_anchored_placement_keeps_its_layer_choice_in_saves_and_exports() { + use designcraft_doc::{AnchoredObject, Content, Graphic, Item, Shape, SpreadRef}; + use designcraft_geom::{Affine, Rect, shapes}; + + let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap(); + let mut d = designcraft_doc::Document::new(&Default::default()); + let asset = designcraft_doc::AssetId(d.alloc()); + d.add_asset(designcraft_doc::Asset { + id: asset, + name: "anchored.pdf".into(), + mime: "application/pdf".into(), + data: std::sync::Arc::new(pdf), + ..Default::default() + }); + let mut item = + Item::new(designcraft_doc::ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.pdf_layer_override = true; + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + layer_options: None, + }); + let layer = d.default_layer(); + let (_, story) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap(); + d.story_mut(story).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() }); + assert!(d.has_pdf_layer_overrides(), "the anchored placement carries the choice"); + let mut s = Session::new(); + s.add_document(crate::DocState::new(d, None)); + // Embedded IDML carries a variant that shows every layer. + let idml = s.execute("file.exportIdml", &json!({})).unwrap(); + let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap(); + let copies: Vec<_> = imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).collect(); + assert!(!copies.is_empty(), "the anchored placement is exported"); + assert!( + copies.iter().all(|a| designcraft_render::pdf_layers(&a.data).iter().all(|(_, on)| *on)), + "the embedded copy must not fall back to the file's defaults" + ); + // An external-only copy would keep the original file's defaults. + assert!(s.execute("file.exportIdml", &json!({"embedImages": false})).is_err()); + // PDF/A-2b rasterises a placement with a choice, warnings included. + let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}"); +} + +/// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a +/// placed-PDF choice: the package report asks for a graphic whose asset is a PDF, the same thing +/// the export walks ask for, so it does not name the stale field, and exports run unaffected. +#[test] +fn a_stale_choice_on_a_non_pdf_placement_is_not_a_placed_pdf_choice() { + let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "photo.png", "x": 0, "y": 0, "width": 100})) + .unwrap()["id"] + .clone(); + let item = designcraft_doc::ItemId(id.as_u64().unwrap()); + // The state a relink from a PDF leaves behind: fields set, asset no longer a PDF. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(item).unwrap().pdf_hidden_layers = vec!["Red".into()]; + let d = &s.doc().unwrap().doc; + assert!(d.item(item).unwrap().has_pdf_layer_override(), "the stale field is on the item"); + assert!(d.has_pdf_layer_overrides(), "the field is there"); + assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice"); + // Nothing is dropped: the field is not a choice any export applies. + s.execute("file.exportIdml", &json!({})).unwrap(); + s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap(); + s.execute("file.exportPdf", &json!({})).unwrap(); +} + +#[test] +fn layerless_pdf_rejects_overrides_without_breaking_idml_export() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone(); + let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone(); + let layered = s + .execute("file.place", &json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100})) + .unwrap()["id"] + .clone(); + let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err()); + assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before); + assert!(s.execute("file.exportIdml", &json!({})).is_ok()); + s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override()); +} + +#[test] +fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let placed = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100})) + .unwrap(); + let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap(); + for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] { + s.execute("selection.set", &json!({"ids":ids})).unwrap(); + assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2); + } + assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error"); + s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap(); + assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err()); + assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err()); +} diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 1053fafc0..4f9aaf1aa 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -464,318 +464,7 @@ pub(super) fn place_text( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn place_text_file_with_autoflow() { - let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12); - std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); - assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}"); - assert_eq!(r["overset"], false); - let d = &s.doc().unwrap().doc; - let sid = StoryId(r["story"].as_u64().unwrap()); - assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count()); - assert_eq!(d.story(sid).unwrap().paras.len(), 60); - // Into the insertion point of another frame. - let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap(); - let sid2 = fr["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap(); - let small = dir.join("small.rtf"); - std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap(); - s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap(); - let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone(); - assert_eq!(st.text, "Start middle end"); - assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold")); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_sets_one_frame_per_column() { - // Regression (#625): autoflow set one frame across the margins, over the gutter. - let dir = std::env::temp_dir().join(format!("dc-place-columns-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - let para = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); - std::fs::write(&path, vec![para.as_str(); 40].join("\n")).unwrap(); - for binding in ["leftToRight", "rightToLeft"] { - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert!(d.page_count() >= 3, "{binding}: {r}"); - // Two frames on every page but maybe the last, in column order, each on its column. - assert!(frames.len() > 2 * (d.page_count() - 1), "{binding}: {} frames", frames.len()); - for (i, f) in frames.iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, i / 2, "{binding}: frame {i}"); - let mut cols = d.page(page).unwrap().column_rects(); - if binding == "rightToLeft" { - cols.reverse(); - } - assert_eq!(d.item(*f).unwrap().bounds(), cols[i % 2], "{binding}: frame {i}"); - } - } - // One column: one frame on the live area per page, as before. - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "margins": 54})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert_eq!(frames.len(), d.page_count()); - for f in frames { - assert_eq!(d.item(*f).unwrap().bounds(), d.page(d.page_of_item(*f).unwrap()).unwrap().margin_rect()); - } - let _ = std::fs::remove_dir_all(&dir); - } - - /// Autoflow follows dragged column guides: one frame per dragged column, on the first page and - /// on every page it adds. - #[test] - fn autoflow_sets_frames_on_dragged_columns() { - let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - // The margin box is 504 pt wide; the gutter is dragged left. There is no command for - // dragging a column guide yet, so set what an import would leave, on the parent. - let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) }; - s.edit(|d, _| { - for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages { - pg.columns = dragged.clone(); - } - d.sync_parent_margins(); - Ok(()) - }) - .unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - let d = &s.doc().unwrap().doc; - assert!(d.page_count() >= 3, "{r}"); - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len()); - for (i, f) in frames.iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, i / 2, "frame {i}"); - let m = d.page(page).unwrap().margin_rect(); - let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2]; - assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_keeps_the_frame_the_user_drew() { - let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 400, 300], "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert_eq!(d.item(frames[0]).unwrap().bounds(), Rect::new(100.0, 100.0, 400.0, 300.0)); - let cols = d.page(1).unwrap().column_rects(); - assert_eq!(d.page_of_item(frames[1]), Some(1)); - assert_eq!(d.item(frames[1]).unwrap().bounds(), cols[0]); - assert_eq!(d.item(frames[2]).unwrap().bounds(), cols[1]); - let _ = std::fs::remove_dir_all(&dir); - } - - /// A facing-pages document with two columns, its inside margin wider than the outside one, so - /// left and right pages have their columns in different places. A graphic frame sits on the - /// page `marker` (0-based). Returns the session and the marker's id. - fn facing_document(pages: usize, marker: usize) -> (Session, u64) { - let mut s = Session::new(); - let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36}); - s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins})) - .unwrap(); - let d = &s.doc().unwrap().doc; - let (si, _) = d.page_loc(marker).unwrap(); - let m = d.page(marker).unwrap().margin_rect(); - let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0]; - let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - // Selected, an empty frame would take the placed text. - s.execute("edit.deselectAll", &json!({})).unwrap(); - (s, id) - } - - /// Every frame of the story on the column of its page, the story running through the pages in - /// order, two frames a page. - fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) { - for (i, f) in frames.iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, i / 2, "frame {i}"); - assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}"); - } - } - - #[test] - fn autoflow_fills_the_pages_that_follow_before_adding_any() { - // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty - // pages the document already had. InDesign threads frames on those first. - let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap(); - let (mut s, marker) = facing_document(6, 2); - let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - assert_eq!(r["pagesAdded"], 0, "{r}"); - let d = &s.doc().unwrap().doc; - assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); - let sid = StoryId(r["story"].as_u64().unwrap()); - let frames = d.story(sid).unwrap().frames.clone(); - // Past page 3, which holds the marker: frames go on a page whatever else it holds. - assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len()); - assert_frames_in_columns(d, &frames); - // The text ends before the document does: no empty frame after it. - let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()))); - assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5); - assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_adds_pages_at_the_end_of_the_document() { - let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap(); - let (mut s, marker) = facing_document(3, 2); - let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - let d = &s.doc().unwrap().doc; - assert!(d.page_count() > 4, "{r}"); - assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}"); - // The document's own pages come first, the added ones after the last of them. - assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids); - assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); - let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone(); - assert_frames_in_columns(d, &frames); - assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1)); - // One undo step takes the frames, the pages and the story away. - s.execute("edit.undo", &json!({})).unwrap(); - let d = &s.doc().unwrap().doc; - assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); - assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none()); - assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_fills_every_column_of_a_following_page() { - // A first frame across the whole live area holds twice what a column does, so autoflow - // asks for half the frames it needs. Asked page by page, it left the later columns of a - // page out of the thread and went on to the next page. - let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); - let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - assert_eq!(r["pagesAdded"], 0, "{r}"); - let d = &s.doc().unwrap().doc; - let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; - assert_eq!(d.item(frames[0]).unwrap().bounds(), m); - assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len()); - for (k, f) in frames[1..].iter().enumerate() { - let page = d.page_of_item(*f).unwrap(); - assert_eq!(page, 1 + k / 2, "frame {}", k + 1); - assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() { - // A small first frame makes autoflow ask for more frames than the columns need; the ones - // it set on the following pages past the end of the text go again, the pages stay. - let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("long.txt"); - std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{r}"); - assert_eq!(r["pagesAdded"], 0, "{r}"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), 8); - let sid = StoryId(r["story"].as_u64().unwrap()); - let frames = &d.story(sid).unwrap().frames; - assert!(frames.len() >= 2, "{r}"); - let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len()); - assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_adds_only_the_pages_breaks_ask_for() { - // Regression (#262): a frame a break ends early made autoflow take it for a frame that - // holds a few characters, and add a page per few characters left (33 for this text). - let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - for (name, rtf, pages) in [ - ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2), - ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4), - ] { - let path = dir.join(name); - std::fs::write(&path, rtf).unwrap(); - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); - assert_eq!(r["overset"], false, "{name}: {r}"); - let d = &s.doc().unwrap().doc; - assert_eq!(d.page_count(), pages, "{name}: {r}"); - assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}"); - } - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { - // From page 1, an odd page break needs pages 2 and 3 (2 stays empty), an even one page 2. - // A frame such a break ends early holds few characters; taken for a full frame, it made - // autoflow add a page per few characters left. - use designcraft_doc::{EVEN_PAGE_BREAK, ODD_PAGE_BREAK, build::NewDocument}; - for (brk, added) in [(ODD_PAGE_BREAK, 2), (EVEN_PAGE_BREAK, 1)] { - let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); - let sid = d.settings.primary_story.unwrap(); - d.story_mut(sid).unwrap().insert( - 0, - &format!( - "a{brk} -{}", - "word ".repeat(200) - ), - ); - assert_eq!(autoflow(&mut d, sid, 50, "file.place", false).unwrap(), added, "{brk:?}"); - assert_eq!(d.page_count(), added + 1, "{brk:?}"); - assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); - } - } -} +mod tests; #[cfg(test)] mod place_pdf_tests { diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs new file mode 100644 index 000000000..006762ab2 --- /dev/null +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -0,0 +1,309 @@ +use super::*; + +#[test] +fn place_text_file_with_autoflow() { + let dir = std::env::temp_dir().join(format!("dc-place-text-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + let para = "The quick brown fox jumps over the lazy dog, again and again, to fill the page. ".repeat(12); + std::fs::write(&path, vec![para.as_str(); 60].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); + assert!(r["pagesAdded"].as_u64().unwrap() >= 2, "{r}"); + assert_eq!(r["overset"], false); + let d = &s.doc().unwrap().doc; + let sid = StoryId(r["story"].as_u64().unwrap()); + assert_eq!(d.story(sid).unwrap().frames.len(), d.page_count()); + assert_eq!(d.story(sid).unwrap().paras.len(), 60); + // Into the insertion point of another frame. + let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Start end"})).unwrap(); + let sid2 = fr["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid2, "anchor": 6, "focus": 6})).unwrap(); + let small = dir.join("small.rtf"); + std::fs::write(&small, br"{\rtf1 {\b middle}}").unwrap(); + s.execute("file.place", &json!({"path": small.to_string_lossy()})).unwrap(); + let st = s.doc().unwrap().doc.story(StoryId(sid2)).unwrap().clone(); + assert_eq!(st.text, "Start middle end"); + assert_eq!(st.format_after(6).over.font_style.as_deref(), Some("Bold")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_sets_one_frame_per_column() { + // Regression (#625): autoflow set one frame across the margins, over the gutter. + let dir = std::env::temp_dir().join(format!("dc-place-columns-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + let para = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); + std::fs::write(&path, vec![para.as_str(); 40].join("\n")).unwrap(); + for binding in ["leftToRight", "rightToLeft"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert!(d.page_count() >= 3, "{binding}: {r}"); + // Two frames on every page but maybe the last, in column order, each on its column. + assert!(frames.len() > 2 * (d.page_count() - 1), "{binding}: {} frames", frames.len()); + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "{binding}: frame {i}"); + let mut cols = d.page(page).unwrap().column_rects(); + if binding == "rightToLeft" { + cols.reverse(); + } + assert_eq!(d.item(*f).unwrap().bounds(), cols[i % 2], "{binding}: frame {i}"); + } + } + // One column: one frame on the live area per page, as before. + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(frames.len(), d.page_count()); + for f in frames { + assert_eq!(d.item(*f).unwrap().bounds(), d.page(d.page_of_item(*f).unwrap()).unwrap().margin_rect()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Autoflow follows dragged column guides: one frame per dragged column, on the first page and +/// on every page it adds. +#[test] +fn autoflow_sets_frames_on_dragged_columns() { + let dir = std::env::temp_dir().join(format!("dc-place-dragged-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 40].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + // The margin box is 504 pt wide; the gutter is dragged left. There is no command for + // dragging a column guide yet, so set what an import would leave, on the parent. + let dragged = designcraft_doc::Columns { count: 2, gutter: 18.0, positions: Some(vec![0.0, 150.0, 170.0, 504.0]) }; + s.edit(|d, _| { + for pg in &mut std::sync::Arc::make_mut(&mut d.parents[0]).pages { + pg.columns = dragged.clone(); + } + d.sync_parent_margins(); + Ok(()) + }) + .unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + assert!(d.page_count() >= 3, "{r}"); + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert!(frames.len() > 2 * (d.page_count() - 1), "{} frames", frames.len()); + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "frame {i}"); + let m = d.page(page).unwrap().margin_rect(); + let (x0, x1) = [(0.0, 150.0), (170.0, 504.0)][i % 2]; + assert_eq!(d.item(*f).unwrap().bounds(), Rect::new(m.x0 + x0, m.y0, m.x0 + x1, m.y1), "frame {i}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_keeps_the_frame_the_user_drew() { + let dir = std::env::temp_dir().join(format!("dc-place-drawn-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 400, 300], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(d.item(frames[0]).unwrap().bounds(), Rect::new(100.0, 100.0, 400.0, 300.0)); + let cols = d.page(1).unwrap().column_rects(); + assert_eq!(d.page_of_item(frames[1]), Some(1)); + assert_eq!(d.item(frames[1]).unwrap().bounds(), cols[0]); + assert_eq!(d.item(frames[2]).unwrap().bounds(), cols[1]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A facing-pages document with two columns, its inside margin wider than the outside one, so +/// left and right pages have their columns in different places. A graphic frame sits on the +/// page `marker` (0-based). Returns the session and the marker's id. +fn facing_document(pages: usize, marker: usize) -> (Session, u64) { + let mut s = Session::new(); + let margins = json!({"top": 54, "bottom": 54, "inside": 72, "outside": 36}); + s.execute("file.new", &json!({"preset": "Letter", "pages": pages, "facingPages": true, "columns": 2, "gutter": 18, "margins": margins})).unwrap(); + let d = &s.doc().unwrap().doc; + let (si, _) = d.page_loc(marker).unwrap(); + let m = d.page(marker).unwrap().margin_rect(); + let rect = [m.x0 + 20.0, m.y0 + 20.0, m.x0 + 120.0, m.y0 + 120.0]; + let id = s.execute("frame.create", &json!({"rect": rect, "spread": si, "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + // Selected, an empty frame would take the placed text. + s.execute("edit.deselectAll", &json!({})).unwrap(); + (s, id) +} + +/// Every frame of the story on the column of its page, the story running through the pages in +/// order, two frames a page. +fn assert_frames_in_columns(d: &Document, frames: &[designcraft_doc::ItemId]) { + for (i, f) in frames.iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, i / 2, "frame {i}"); + assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[i % 2], "frame {i}"); + } +} + +#[test] +fn autoflow_fills_the_pages_that_follow_before_adding_any() { + // Gap 26: autoflow inserted pages after the first frame's page, in front of the empty + // pages the document already had. InDesign threads frames on those first. + let dir = std::env::temp_dir().join(format!("dc-place-following-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 24].join("\n")).unwrap(); + let (mut s, marker) = facing_document(6, 2); + let ids: Vec<_> = (0..6).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); + let sid = StoryId(r["story"].as_u64().unwrap()); + let frames = d.story(sid).unwrap().frames.clone(); + // Past page 3, which holds the marker: frames go on a page whatever else it holds. + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() > 2, "{} frames", frames.len()); + assert_frames_in_columns(d, &frames); + // The text ends before the document does: no empty frame after it. + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()))); + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() < 5); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_adds_pages_at_the_end_of_the_document() { + let dir = std::env::temp_dir().join(format!("dc-place-end-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 60].join("\n")).unwrap(); + let (mut s, marker) = facing_document(3, 2); + let ids: Vec<_> = (0..3).map(|i| s.doc().unwrap().doc.page(i).unwrap().id).collect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "page": 1, "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + let d = &s.doc().unwrap().doc; + assert!(d.page_count() > 4, "{r}"); + assert_eq!(r["pagesAdded"].as_u64(), Some(d.page_count() as u64 - 3), "{r}"); + // The document's own pages come first, the added ones after the last of them. + assert_eq!((0..3).map(|i| d.page(i).unwrap().id).collect::>(), ids); + assert_eq!(d.page_of_item(designcraft_doc::ItemId(marker)), Some(2)); + let frames = d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.clone(); + assert_frames_in_columns(d, &frames); + assert_eq!(d.page_of_item(*frames.last().unwrap()), Some(d.page_count() - 1)); + // One undo step takes the frames, the pages and the story away. + s.execute("edit.undo", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!((0..d.page_count()).map(|i| d.page(i).unwrap().id).collect::>(), ids); + assert!(d.story(StoryId(r["story"].as_u64().unwrap())).is_none()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_fills_every_column_of_a_following_page() { + // A first frame across the whole live area holds twice what a column does, so autoflow + // asks for half the frames it needs. Asked page by page, it left the later columns of a + // page out of the thread and went on to the next page. + let dir = std::env::temp_dir().join(format!("dc-place-under-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 30].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 6, "facingPages": false, "columns": 2, "gutter": 18, "margins": 54})).unwrap(); + let m = s.doc().unwrap().doc.page(0).unwrap().margin_rect(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [m.x0, m.y0, m.x1, m.y1], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + let frames = &d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames; + assert_eq!(d.item(frames[0]).unwrap().bounds(), m); + assert!(d.page_of_item(*frames.last().unwrap()).unwrap() >= 3, "{} frames", frames.len()); + for (k, f) in frames[1..].iter().enumerate() { + let page = d.page_of_item(*f).unwrap(); + assert_eq!(page, 1 + k / 2, "frame {}", k + 1); + assert_eq!(d.item(*f).unwrap().bounds(), d.page(page).unwrap().column_rects()[k % 2], "frame {}", k + 1); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_leaves_no_empty_frame_on_the_pages_that_follow() { + // A small first frame makes autoflow ask for more frames than the columns need; the ones + // it set on the following pages past the end of the text go again, the pages stay. + let dir = std::env::temp_dir().join(format!("dc-place-small-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("long.txt"); + std::fs::write(&path, vec!["Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(12); 12].join("\n")).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "Letter", "pages": 8, "facingPages": false, "margins": 54})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "rect": [100, 100, 200, 130], "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{r}"); + assert_eq!(r["pagesAdded"], 0, "{r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), 8); + let sid = StoryId(r["story"].as_u64().unwrap()); + let frames = &d.story(sid).unwrap().frames; + assert!(frames.len() >= 2, "{r}"); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(frames.iter().all(|f| cs.frame(*f).is_some_and(|ft| !ft.range.is_empty())), "{} frames", frames.len()); + assert_eq!(d.spreads.iter().map(|sp| sp.items.len()).sum::(), frames.len()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_adds_only_the_pages_breaks_ask_for() { + // Regression (#262): a frame a break ends early made autoflow take it for a frame that + // holds a few characters, and add a page per few characters left (33 for this text). + let dir = std::env::temp_dir().join(format!("dc-place-breaks-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, rtf, pages) in [ + ("lead.rtf", r"{\rtf1 \page\par A Short Heading\par First short paragraph.\par Second short paragraph.\par}", 2), + ("each.rtf", r"{\rtf1 One\par\page\par Two\par\page\par Three\par\page\par Four\par}", 4), + ] { + let path = dir.join(name); + std::fs::write(&path, rtf).unwrap(); + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let r = s.execute("file.place", &json!({"path": path.to_string_lossy(), "autoflow": true})).unwrap(); + assert_eq!(r["overset"], false, "{name}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{name}: {r}"); + assert_eq!(d.story(StoryId(r["story"].as_u64().unwrap())).unwrap().frames.len(), pages, "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { + // From page 1, an odd page break needs pages 2 and 3 (2 stays empty), an even one page 2. + // A frame such a break ends early holds few characters; taken for a full frame, it made + // autoflow add a page per few characters left. + use designcraft_doc::{EVEN_PAGE_BREAK, ODD_PAGE_BREAK, build::NewDocument}; + for (brk, added) in [(ODD_PAGE_BREAK, 2), (EVEN_PAGE_BREAK, 1)] { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + d.story_mut(sid).unwrap().insert( + 0, + &format!( + "a{brk} +{}", + "word ".repeat(200) + ), + ); + assert_eq!(autoflow(&mut d, sid, 50, "file.place", false).unwrap(), added, "{brk:?}"); + assert_eq!(d.page_count(), added + 1, "{brk:?}"); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); + } +} diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 19d64c1fc..390dc7118 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { let xf = parent * it.xf; pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf))); let page = page_of(d, SpreadRef::Doc(si), it, parent); + if let Content::Graphic(g) = &it.content + && g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) + { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: Some(it.id.0), + page, + }); + } match &it.content { Content::Graphic(g) => match d.assets.get(&g.asset) { None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }), @@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } } } + // Nested graphic cells and anchored/parent items may not have a page frame + // in the walk above. Keep the warning visible after a native save/reopen. + if d.has_unwalked_graphic_layer_visibility_overrides() { + out.push(Issue { + severity: "warning", + kind: "unsupportedGraphicLayers", + message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(), + item: None, + page: None, + }); + } out } @@ -354,11 +376,16 @@ mod placed_vector_tests { s.execute("file.place", &json!({"base64": base64_encode(&png), "name": "photo.jpg", "x": 72, "y": 72, "width": 20})).unwrap(); let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let asset = doc.assets.values_mut().next().unwrap(); - std::sync::Arc::make_mut(asset).link = Some("/definitely/missing dir/photo.jpg".into()); + // On Windows, `/x` names no drive, so `document_path` refuses it when the document has no path. `TMPDIR` can be + // relative on macOS and Linux, so the temporary folder is made absolute. + let temp = std::path::absolute(std::env::temp_dir()).unwrap(); + let link = temp.join(format!("dc-preflight-missing-{} dir", std::process::id())).join("photo.jpg"); + let link = link.to_string_lossy().into_owned(); + std::sync::Arc::make_mut(asset).link = Some(link.clone()); let r = s.execute("preflight.run", &json!({})).unwrap(); let missing: Vec<&str> = r["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "missingLink").map(|i| i["message"].as_str().unwrap()).collect(); - assert_eq!(missing, ["Missing link: /definitely/missing dir/photo.jpg"]); + assert_eq!(missing, [format!("Missing link: {link}")]); } } diff --git a/crates/engine/src/cmd/prepress.rs b/crates/engine/src/cmd/prepress.rs new file mode 100644 index 000000000..4eb15a6eb --- /dev/null +++ b/crates/engine/src/cmd/prepress.rs @@ -0,0 +1,96 @@ +//! Window › Output › Separations Preview, for automation: the plates a spread's print PDF +//! prints, with ink coverage and the areas over an ink limit. + +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc}; +use crate::{Result, Session}; + +const ID: &str = "ink.separations"; + +pub fn specs() -> Vec { + vec![cmd!(query "ink.separations", "Separations Preview", [], None, + "{page?: 1-based page position (default 1; its spread is separated), dpi?: 9–300 (default 72), inkLimit?: percent, standard?: none|x4|a2b, compressImages?: bool, flatten?: high|medium|low|ppi (as file.exportPdf)} — the spread exported as Export PDF prints it and separated → {spread, dpi, width, height, conversion, inks: [{name, spot, max, mean, area} (percent)], unseparated: [spot names past 32], maxTotal, maxTotalAt?: [x, y] (pt, spread), overLimit?: {limit, pixels, regions: [{x, y, width, height}] (pt, spread)}, warnings}", + has_doc, separations)] +} + +fn separations(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + let page = match p.get("page") { + None | Some(Value::Null) => 0, + Some(v) => v + .as_u64() + .and_then(|n| usize::try_from(n).ok()) + .filter(|n| (1..=d.page_count()).contains(n)) + .map(|n| n - 1) + .ok_or_else(|| bad(ID, format!("bad page {v}")))?, + }; + let (si, _) = d.page_loc(page).ok_or_else(|| bad(ID, "the document has no pages"))?; + let dpi = match p.get("dpi") { + None | Some(Value::Null) => 72.0, + Some(v) => v + .as_f64() + .filter(|v| (f64::from(crate::separations::MIN_DPI)..=f64::from(crate::separations::MAX_DPI)).contains(v)) + .ok_or_else(|| bad(ID, "`dpi`: 9–300"))? as f32, + }; + let limit = match p.get("inkLimit") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| bad(ID, "`inkLimit`: a percentage"))? as f32), + }; + let standard = match super::str_param(p, "standard") { + Some(v) => designcraft_pdf::Standard::parse(v).ok_or_else(|| bad(ID, format!("unknown standard `{v}` (none, x4, a2b)")))?, + None => designcraft_pdf::Standard::None, + }; + let print = crate::separations::Print { + standard, + compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false), + flatten: super::export::flatten_ppi(ID, p)?, + }; + let sheet = crate::separations::separate(d, &s.cache, si, dpi, &print)?; + let pl = &sheet.plates; + let stats = pl.stats(&[]); + let inks: Vec = pl + .inks + .iter() + .zip(&stats.inks) + .map(|((_, ink), st)| json!({"name": ink.name(), "spot": ink.is_spot(), "max": st.max, "mean": st.mean, "area": st.area})) + .collect(); + let conversion = match &pl.conversion { + craft_prepress::Conversion::OutputIntent { profile, .. } => format!("output intent: {profile}"), + craft_prepress::Conversion::Fallback { profile } => profile.clone(), + craft_prepress::Conversion::DeviceFormulas => "device formulas".into(), + // `Conversion` is non-exhaustive; a later craft-prepress conversion reports its debug form. + other => format!("{other:?}"), + }; + let pt = |x: u32, y: u32| { + let p = sheet.to_spread(x, y); + [p.x, p.y] + }; + let mut out = json!({ + "spread": si, + "dpi": pl.dpi, + "width": pl.width, + "height": pl.height, + "conversion": conversion, + "inks": inks, + "unseparated": pl.unseparated, + "maxTotal": stats.max_total, + "maxTotalAt": stats.max_total_at.map(|(x, y)| pt(x, y)), + "warnings": sheet.warnings, + }); + if let Some(limit) = limit { + let above = pl.above(limit, &[]); + let k = 72.0 / f64::from(pl.dpi.max(f32::MIN_POSITIVE)); + let regions: Vec = above + .regions() + .iter() + .map(|r| { + let [x, y] = pt(r.x, r.y); + json!({"x": x, "y": y, "width": f64::from(r.width) * k, "height": f64::from(r.height) * k}) + }) + .collect(); + out["overLimit"] = json!({"limit": limit, "pixels": above.count, "regions": regions}); + } + Ok(out) +} diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 0bb280780..e71c230de 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -5,8 +5,6 @@ use serde_json::{Value, json}; #[cfg(not(target_arch = "wasm32"))] use super::str_param; use super::{CommandSpec, bad, cmd, has_doc}; -#[cfg(not(target_arch = "wasm32"))] -use crate::EngineError; use crate::{Result, Session}; pub fn specs() -> Vec { @@ -17,7 +15,7 @@ pub fn specs() -> Vec { "Print…", ["File"], Some("Cmd+P"), - "{printer?, copies?: 1, pages?: \"1-3,5\", spreads?, marks?, bleed?, dryRun?: bool (export and preview submission without printing), created?: int (Unix seconds, as file.exportPdf)} → {printer, copies, pages, command, bytes}", + "{printer?, copies?: 1, pages?: \"1-3,5\", dryRun?: bool (export and preview submission without printing), …the PDF settings of file.exportPdf (the PDF it writes is what prints)} → {printer, copies, pages, command, bytes, warnings}", has_doc, print ), @@ -58,12 +56,14 @@ fn print_with( ..craft_print::Job::default() }; let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); - let pdf = designcraft_pdf::export_pdf(&super::interchange::with_pdf_boxes(d), &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?; - let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?; let pages = opts.pages.as_ref().map_or(d.page_count(), |v| v.len()); - let out = json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.len()}); + // The PDF that Export PDF writes with these settings. + let pdf = super::export::pdf_export(s, p)?; + let command = craft_print::command_preview(&job).map_err(|e| bad("file.print", e.to_string()))?; + let out = + json!({"printer": job.printer, "copies": copies, "pages": pages, "command": command, "bytes": pdf.bytes.len(), "warnings": pdf.warnings}); if !dry { - submit(&pdf, &job).map_err(|e| bad("file.print", e.to_string()))?; + submit(&pdf.bytes, &job).map_err(|e| bad("file.print", e.to_string()))?; } Ok(out) } @@ -84,7 +84,10 @@ mod tests { assert!(command.contains(&"print-scaling=auto-fit")); assert!(!command.iter().any(|a| a.starts_with("sides="))); } else { - assert_eq!(command[0], "powershell"); + assert_eq!( + command, + ["xps-print", "printer=Office", "copies=2", "collate=true", "duplex=(queue default)", "scaling=shrink-to-fit", "color=color"] + ); } assert!(r["bytes"].as_u64().unwrap() > 500); assert!(s.execute("file.print", &json!({"pages": "9", "dryRun": true})).is_err()); @@ -97,11 +100,7 @@ mod tests { s.execute("file.new", &json!({"pages": 4})).unwrap(); // A fixed creation date: both exports would otherwise take the clock's second. let params = json!({"printer": "Office (east)", "copies": 2, "pages": "2-3", "marks": true, "bleed": true, "created": 1_700_000_000}); - let expected = { - let d = &s.doc().unwrap().doc; - let options = super::super::export::options(¶ms, d.page_count()).unwrap(); - designcraft_pdf::export_pdf(&super::super::interchange::with_pdf_boxes(d), &s.cache, &options).unwrap() - }; + let expected = super::super::export::pdf_export(&s, ¶ms).unwrap().bytes; let r = print_with(&mut s, ¶ms, |pdf, job| { // The production export options and full bytes reach the shared spooler boundary. assert_eq!(pdf, expected); @@ -121,4 +120,34 @@ mod tests { assert!(print_with(&mut s, &json!({"pages": "9"}), |_, _| panic!("invalid ranges must not spool")).is_err()); assert!(print_with(&mut s, &json!({"created": "today"}), |_, _| panic!("an invalid date must not spool")).is_err()); } + + /// File › Print spools the PDF that File › Export PDF writes with the same settings: soft + /// effects as images, CMYK print color, the same warnings. + #[test] + fn print_spools_what_export_pdf_writes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + s.execute("object.dropShadow", &json!({"ids": [id], "on": true, "distance": 8, "size": 6})).unwrap(); + let params = json!({"standard": "x4"}); + // The creation date is in whole seconds: an attempt can straddle a second boundary. + let mut matched = false; + for _ in 0..3 { + let exported = s.execute("file.exportPdf", ¶ms).unwrap(); + let mut printed = Vec::new(); + let r = print_with(&mut s, ¶ms, |pdf, _| { + printed = pdf.to_vec(); + Ok("request id is Office-1".into()) + }) + .unwrap(); + assert_eq!(r["warnings"], exported["warnings"]); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("soft effects")), "{r}"); + if printed == super::super::file::base64_decode(exported["base64"].as_str().unwrap()) { + matched = true; + break; + } + } + assert!(matched, "File › Print spools Export PDF's bytes"); + } } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d1bd06ad0..4cc8a5894 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -1,203 +1,5 @@ //! Paragraph / character styles and swatches. -#[cfg(test)] -mod redefine_tests { - use super::*; - - fn sample() -> (Session, Value, Value) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"First header"})).unwrap(); - let b = s.execute("frame.create", &json!({"rect":[36,240,300,400],"content":"text","text":"Second header"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name":"Header","chars":{"size":18,"fill":"[Paper]"}})).unwrap(); - for story in [&a["story"], &b["story"]] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name":"Header"})).unwrap(); - } - s.execute("text.select", &json!({"story":a["story"],"anchor":0,"focus":5})).unwrap(); - (s, a["story"].clone(), b["story"].clone()) - } - - #[test] - fn redefine_paragraph_updates_other_stories_and_clears_the_source_overrides() { - let (mut s, a, b) = sample(); - s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); - s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); - s.execute("type.char", &json!({"size":20,"fill":"[Black]"})).unwrap(); - s.execute("type.para", &json!({"spaceAfter":9})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("style.paragraph.redefine", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - assert!(s.doc().unwrap().doc.styles.para("Header").unwrap().chars.underline.is_none()); - for story in [&a, &b] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); - assert_eq!(attrs["chars"]["size"], 20.0); - assert_eq!(attrs["chars"]["fill"], "[Black]"); - assert_eq!(attrs["para"]["spaceAfter"], 9.0); - assert_eq!(attrs["chars"]["underline"], story == &a); - } - s.execute("style.paragraph.edit", &json!({"name":"Header","chars":{"size":22}})).unwrap(); - s.execute("text.select", &json!({"story":a,"anchor":0,"focus":5})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 22.0); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - } - - #[test] - fn redefine_character_does_not_capture_paragraph_font_size() { - let (mut s, a, b) = sample(); - s.execute("style.character.create", &json!({"name":"Underline","chars":{"underline":true}})).unwrap(); - for story in [&a, &b] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - s.execute("style.character.apply", &json!({"name":"Underline"})).unwrap(); - } - s.execute("type.char", &json!({"fill":"[Black]"})).unwrap(); - s.execute("style.character.redefine", &json!({"name":"Underline"})).unwrap(); - let style = s.doc().unwrap().doc.styles.char_style("Underline").unwrap(); - assert!(style.chars.size.is_none()); - assert_eq!(style.chars.underline, Some(true)); - for story in [&a, &b] { - s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); - } - } - - #[test] - fn redefine_rejects_the_wrong_style_and_the_none_entry_atomically() { - let (mut s, _, _) = sample(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - assert!(s.execute("style.paragraph.redefine", &json!({"name":"Other"})).is_err()); - assert!(s.execute("style.character.redefine", &json!({})).is_err()); - assert_eq!(s.doc().unwrap().doc, before); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - - #[test] - fn redefine_character_at_a_caret_cleans_the_source_run_but_keeps_other_overrides() { - let (mut s, story, _) = sample(); - s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); - for (start, end, size) in [(0, 5, 24), (6, 12, 32)] { - s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); - s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); - s.execute("type.char", &json!({"size":size})).unwrap(); - } - let before = s.doc().unwrap().doc.clone(); - s.execute("text.select", &json!({"story":story,"anchor":2,"focus":2})).unwrap(); - s.execute("style.character.redefine", &json!({"name":"Emphasis"})).unwrap(); - s.execute("style.character.edit", &json!({"name":"Emphasis","chars":{"size":30}})).unwrap(); - for (start, end, size) in [(0, 5, 30.0), (6, 12, 32.0)] { - s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], size); - } - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - } -} - -#[cfg(test)] -mod application_inheritance_tests { - use super::*; - - fn setup(text: &str) -> (Session, Value) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [36,36,300,300], "content": "text", "text": text})).unwrap(); - (s, f["story"].clone()) - } - fn select(s: &mut Session, story: &Value, start: usize, end: usize) { - s.execute("text.select", &json!({"story": story, "anchor": start, "focus": end})).unwrap(); - } - fn attrs(s: &mut Session) -> Value { - s.execute("type.selectionAttrs", &json!({})).unwrap() - } - - #[test] - fn paragraph_capture_source_follows_later_style_edits_and_undo() { - let (mut s, story) = setup("First\nSecond"); - select(&mut s, &story, 0, 5); - s.execute("type.char", &json!({"size": 18, "fill": "[Paper]"})).unwrap(); - s.execute("type.para", &json!({"spaceAfter": 6})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Header", "fromSelection": true})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc, before); - s.execute("edit.redo", &json!({})).unwrap(); - select(&mut s, &story, 6, 6); - s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); - s.execute("style.paragraph.edit", &json!({"name": "Header", "chars": {"size": 20, "fill": "[Black]"}, "para": {"spaceAfter": 12}})).unwrap(); - for pos in [0, 6] { - select(&mut s, &story, pos, pos + 1); - let a = attrs(&mut s); - assert_eq!(a["chars"]["size"], 20.0); - assert_eq!(a["chars"]["fill"], "[Black]"); - assert_eq!(a["para"]["spaceAfter"], 12.0); - } - } - - #[test] - fn applying_styles_keeps_different_overrides_and_named_character_styles() { - let (mut s, story) = setup("First\nSecond"); - select(&mut s, &story, 0, 5); - s.execute("type.char", &json!({"size": 14})).unwrap(); - s.execute("type.para", &json!({"spaceAfter": 9})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 10}, "para": {"spaceAfter": 6}})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Body"})).unwrap(); - let a = attrs(&mut s); - assert_eq!(a["chars"]["size"], 14.0); - assert_eq!(a["para"]["spaceAfter"], 9.0); - assert_eq!(a["characterStyle"], "Emphasis"); - assert_eq!(a["chars"]["underline"], true); - } - - #[test] - fn character_capture_source_remains_sparse_and_follows_style_edits() { - let (mut s, story) = setup("First\nSecond"); - select(&mut s, &story, 0, 5); - s.execute("type.char", &json!({"underline": true})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "fromSelection": true})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"underline": false}})).unwrap(); - assert_eq!(attrs(&mut s)["chars"]["underline"], false); - assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").unwrap().chars.size.is_none()); - s.execute("style.paragraph.edit", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "chars": {"size": 20}})).unwrap(); - assert_eq!(attrs(&mut s)["chars"]["size"], 20.0); - } - - #[test] - fn paragraph_selection_excludes_the_next_paragraph_at_its_start() { - let (mut s, story) = setup("é\nNext"); - select(&mut s, &story, 0, 3); - s.execute("style.paragraph.create", &json!({"name": "Header"})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Header", "clearOverrides": true})).unwrap(); - select(&mut s, &story, 3, 4); - assert_eq!(attrs(&mut s)["paragraphStyle"], designcraft_doc::BASIC_PARAGRAPH); - select(&mut s, &story, 0, 2); - assert_eq!(attrs(&mut s)["paragraphStyle"], "Header"); - } - - #[test] - fn clearing_a_partial_selection_preserves_character_overrides_outside_it() { - let (mut s, story) = setup("First Second"); - select(&mut s, &story, 0, 12); - s.execute("type.char", &json!({"size": 18})).unwrap(); - select(&mut s, &story, 0, 5); - s.execute("style.paragraph.apply", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "clearOverrides": true})).unwrap(); - assert_ne!(attrs(&mut s)["chars"]["size"], 18.0); - select(&mut s, &story, 6, 12); - assert_eq!(attrs(&mut s)["chars"]["size"], 18.0); - } -} - use designcraft_color::{Swatch, SwatchValue}; use designcraft_doc::{CharAttrs, CharacterStyle, Effects, Item, ObjectStyle, ParaAttrs, ParagraphStyle, Styles}; use serde_json::{Value, json}; @@ -205,6 +7,20 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, ok, str_param}; use crate::{Result, Session}; +mod color; +mod object; +mod paragraph; +mod swatches; + +use color::{apply_color, apply_gradient, has_text_or_selection, sample_color}; +use object::{apply_object, clear_object_overrides, create_object, edit_object}; +use paragraph::{ + apply_char, apply_para, create_char, create_para, delete_char, delete_para, edit_char, edit_para, export_tag_key, group_styles, redefine, +}; +pub(crate) use paragraph::{check_grep_styles, grep_style_warnings}; +pub use swatches::parse_color; +use swatches::{add_to_swatches, create_swatch, load_swatches, new_swatch, save_swatches, swatch_options}; + pub fn specs() -> Vec { vec![ cmd!(query "style.compositeFont.list", "List Composite Fonts", [], None, "{} → composite font definitions", has_doc, |s, _| { @@ -249,7 +65,7 @@ pub fn specs() -> Vec { "Export Tagging", [], None, - "{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?} → the tagging", + "{style, character?: bool, tag?: p|h1…h6|blockquote|pre|li|… (span|em|strong|code|sup|sub… for characters; \"\" = automatic), class?, split?: bool (paragraphs: Split Document, EPUB only)} → the tagging", has_doc, |s, p| { let name = str_param(p, "style").ok_or_else(|| bad("style.exportTag", "`style` required"))?.to_string(); @@ -258,8 +74,12 @@ pub fn specs() -> Vec { if !exists { return Err(bad("style.exportTag", format!("no style `{name}`"))); } - let key = format!("{}:{name}", if character { "c" } else { "p" }); + let key = export_tag_key(!character, &name); let (tag, class) = (str_param(p, "tag").map(str::to_string), str_param(p, "class").map(str::to_string)); + let split = p.get("split").and_then(Value::as_bool); + if character && split == Some(true) { + return Err(bad("style.exportTag", "`split` is for paragraph styles")); + } s.edit(|d, _| { let st = d.styles_mut(); let e = st.export_tags.entry(key.clone()).or_default(); @@ -269,8 +89,11 @@ pub fn specs() -> Vec { if let Some(c) = class.clone() { e.class = c; } + if let Some(v) = split { + e.split = v; + } let out = serde_json::to_value(&*e).unwrap_or_default(); - if e.tag.is_empty() && e.class.is_empty() { + if *e == designcraft_doc::ExportTag::default() { st.export_tags.remove(&key); } Ok(out) @@ -302,7 +125,7 @@ pub fn specs() -> Vec { "New Paragraph Style…", [], None, - "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", + "{name, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, firstLineIndent, spaceBefore, spaceAfter, ignoreOpticalMargin, …; startAt?: null = continue numbering; grepStyles?: [{style, pattern}]: a pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}, fromSelection?: bool}", has_doc, create_para ), @@ -311,7 +134,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", + "{name, rename?, basedOn?, nextStyle?, para?: {paragraph attributes, as type.para names them: align, leftIndent, spaceBefore, ignoreOpticalMargin, …; ruleAbove?/ruleBelow?: only the named rule fields change; startAt?: null = continue numbering; kinsoku?: set | null (default rules); a grepStyles pattern that can't compile is an error}, chars?: {character attributes, as type.char names them: fontFamily, fontStyle, size, leading, tracking, fill, …}}", has_doc, edit_para ), @@ -404,7 +227,7 @@ pub fn specs() -> Vec { "Gradient", [], None, - "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: name (its colour, as dropped on the Gradient panel), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)", + "{kind?: linear|radial, stops?: [{location: 0–100, color: \"#rrggbb\"|{c,m,y,k}(0..100)|[r,g,b](0..255)|{model: rgb|cmyk|gray, …}(0..1) | swatch: a color swatch's name (as dropped on the Gradient panel: its color on screen, its inks in print), opacity?: 0–100, midpoint?: 13–87}], reverse?: bool, angle?: degrees, from?: [x,y], to?: [x,y] (spread coords: the Gradient Swatch tool's drag), target?: fill|stroke, ids?} → {swatch, kind, stops: [{location, color, swatch?, opacity, midpoint}]} — edits the fill's (or the stroke's) gradient (an unnamed gradient unless it equals a gradient swatch)", super::has_selection, apply_gradient ), @@ -490,6 +313,7 @@ pub fn specs() -> Vec { return Err(bad("swatch.delete", "special swatches can't be deleted")); } d.swatches.retain(|w| w.name != name); + d.forget_gradient_stop_swatch(&name); for g in &mut d.color_groups { g.swatches.retain(|w| *w != name); } @@ -498,13 +322,14 @@ pub fn specs() -> Vec { }), cmd!(query "ink.list", "Ink Manager", [], None, "{} → {allToProcess, inks: [{name, process: bool (printed as process), alias?}]} — the spot inks", has_doc, |s, _| { let d = &s.doc()?.doc; + let inks = designcraft_pdf::inks::ink_set(d); let inks: Vec = d .swatches .iter() .filter(|w| matches!(w.value, designcraft_color::swatch::SwatchValue::Color { color_type: designcraft_color::swatch::ColorType::Spot, .. })) .map(|w| { let alias = d.inks.aliases.iter().find(|(a, _)| *a == w.name).map(|(_, t)| t.clone()); - json!({"name": w.name, "process": d.inks.resolve(&w.name).1, "alias": alias}) + json!({"name": w.name, "process": designcraft_pdf::inks::spot_output(d, &inks, &w.name).1, "alias": alias}) }) .collect(); Ok(json!({"allToProcess": d.inks.all_to_process, "inks": inks})) @@ -550,6 +375,13 @@ pub fn specs() -> Vec { } match alias { Some(Value::String(to)) => { + let mut set = designcraft_pdf::inks::ink_set(d); + let ids = (set.lookup(name), set.lookup(&to)); + if let (Some(from), Some(target)) = ids + && !set.alias(from, target) + { + return Err(bad("ink.options", format!("aliasing `{name}` to `{to}` would make a loop"))); + } d.inks.aliases.retain(|(a, _)| a != name); d.inks.aliases.push((name.clone(), to)); } @@ -649,2921 +481,8 @@ pub fn specs() -> Vec { ] } -/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute -/// is that command's parameter error, naming the group. -fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { - let mut a = T::default(); - match p.get(group) { - None | Some(Value::Null) => {} - Some(Value::Object(o)) => { - for (k, v) in o { - set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?; - } - } - Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))), - } - Ok(a) -} - -fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { - let command = if paragraph { "style.paragraph.redefine" } else { "style.character.redefine" }; - let targets = super::text::format_targets_pub(s); - let target = targets.first().ok_or_else(|| bad(command, "select text using the style to redefine"))?; - let state = s.doc()?; - let story = state.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(command, "no paragraph at the selection"))?; - let cf = super::text::effective_char_format(s, target)?; - let selected = if paragraph { &pf.style } else { &cf.style }; - let name = str_param(p, "name").unwrap_or(selected).to_string(); - if name != *selected || name == if paragraph { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { - return Err(bad(command, "select text using the named style; the no-style entry cannot be redefined")); - } - let (base_para, base_chars) = state.doc.styles.resolve_para_style(&pf.style); - let (resolved_para, paragraph_chars) = state.doc.styles.resolve_para(pf); - let para_changes = base_para.delta(&resolved_para); - let mut current = if paragraph { - paragraph_chars.clone() - } else { - state.doc.styles.resolve_char(¶graph_chars, &designcraft_doc::CharFormat { style: name.clone(), over: CharAttrs::default() }) - }; - let inherited = if paragraph { base_chars } else { current.clone() }; - // A named character style is an intentional layer, not a paragraph override to absorb. - current.apply(&cf.over); - let char_changes = inherited.delta(¤t); - let result = s.edit(|d, _| { - if paragraph { - let style = d.styles_mut().para_mut(&name).ok_or_else(|| bad(command, "paragraph style no longer exists"))?; - style.para.merge(¶_changes); - style.chars.merge(&char_changes); - } else { - let style = - d.styles_mut().character.iter_mut().find(|st| st.name == name).ok_or_else(|| bad(command, "character style no longer exists"))?; - style.chars.merge(&char_changes); - } - let styles = d.styles.clone(); - for target in &targets { - let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; - // At a character-style caret, the sampled format belongs to the containing run - // (the preceding run at a boundary). Normalize that source run too, otherwise its - // old local values would mask the next edit of the redefined style. - let mut selected_range = target.range.clone(); - if !paragraph && selected_range.is_empty() { - let caret = selected_range.start; - let mut start = 0usize; - for run in &story.chars { - let end = start.saturating_add(run.len).min(story.len()); - if caret < end || (caret == end && caret > start) { - selected_range = start..end; - break; - } - start = end; - } - } - let end = if selected_range.is_empty() { selected_range.start } else { selected_range.end.saturating_sub(1) }; - let (first, last) = (story.para_at(selected_range.start), story.para_at(end)); - for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - let Some(pf) = story.paras.get_mut(index) else { continue }; - if paragraph && pf.style != name { - continue; - } - if paragraph { - let (pp, cp) = styles.resolve_para(pf); - let (base_p, base_c) = styles.resolve_para_style(&name); - pf.para = base_p.delta(&pp); - pf.chars = base_c.delta(&cp); - } - let (_, base) = styles.resolve_para(pf); - let end = range.end.saturating_add(1).min(story.len()); - let range = if paragraph { range.start..end } else { range.start.max(selected_range.start)..end.min(selected_range.end) }; - story.format_chars(range, |format| { - if !paragraph && format.style != name { - return; - } - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&format.style)); - let resolved = styles.resolve_char(&base, format); - format.over = inherited.delta(&resolved); - }); - } - } - Ok(json!({"name":name})) - }); - if result.is_ok() { - s.typing_format = None; - } - result -} - -fn apply_para(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); - let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); - if s.doc()?.doc.styles.para(&name).is_none() { - return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`"))); - } - let targets = super::text::format_targets_pub(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - let (style_para, style_chars) = styles.resolve_para_style(&name); - for t in &targets { - let r = &t.range; - if let Some(st) = d.text_story_mut(t.story, t.cell) { - // Text selections are half-open: a selection ending at the next paragraph's - // start must not apply a style to that paragraph too. - let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; - let (first, last) = (st.para_at(r.start), st.para_at(end)); - st.format_paras(r.start..end, |f| { - f.style = name.clone(); - if clear { - f.para = ParaAttrs::default(); - f.chars = CharAttrs::default(); - } else { - f.para.retain_differences(&style_para); - f.chars.retain_differences(&style_chars); - } - }); - for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, base) = styles.resolve_para(pf); - let end = range.end.saturating_add(1).min(st.len()); - let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; - st.format_chars(range, |f| { - if clear { - f.over = CharAttrs::default(); - } else { - let mut inherited = base.clone(); - inherited.apply(&styles.char_style_attrs(&f.style)); - f.over.retain_differences(&inherited); - } - }); - } - } - } - ok() - }) -} - -fn apply_char(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); - if s.doc()?.doc.styles.char_style(&name).is_none() { - return Err(bad("style.character.apply", format!("no character style `{name}`"))); - } - let targets = super::text::format_targets_pub(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - if let Some(st) = d.text_story_mut(t.story, t.cell) { - for (pi, range) in st.para_ranges().into_iter().enumerate() { - let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); - if range.is_empty() { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, mut inherited) = styles.resolve_para(pf); - inherited.apply(&styles.char_style_attrs(&name)); - st.format_chars(range, |f| { - f.style = name.clone(); - f.over.retain_differences(&inherited); - }); - } - } - } - ok() - }) -} - -/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit -/// "continue" so it also overrides a Start At the style would inherit. -fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { - let explicit = - p.and_then(Value::as_object).is_some_and(|o| o.iter().any(|(k, v)| v.is_null() && k.replace('_', "").eq_ignore_ascii_case("startAt"))); - if explicit { - para.start_at = Some(None); - } -} - -fn create_para(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); - let mut para: ParaAttrs = - attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; - continue_numbering(p.get("para"), &mut para); - explicit_default_kinsoku(&mut para, p.get("para")); - check_grep_styles("style.paragraph.create", ¶)?; - let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; - if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) - && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) - && !cur.is_null() - { - if let Ok(pp) = serde_json::from_value::(cur["para"].clone()) { - let mut full = ParaProps_to_attrs(&pp); - full.merge(¶); - para = full; - } - if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) { - let mut full = CharProps_to_attrs(&cp); - full.merge(&chars); - chars = full; - } - } - let based_on = str_param(p, "basedOn").map(str::to_string); - let next = str_param(p, "nextStyle").map(str::to_string); - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); - if let Some(b) = &based_on - && d.styles.para(b).is_none() - { - return Err(bad("style.paragraph.create", format!("no style `{b}`"))); - } - d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); - Ok(json!({"name": name})) - }) -} - -/// `kinsoku: null` sets the composer's default kinsoku rules, also over a Based On style's set. -/// For the other attributes `null` leaves the style's value as it is. -fn explicit_default_kinsoku(para: &mut ParaAttrs, given: Option<&Value>) { - if given.and_then(|o| o.get("kinsoku")).is_some_and(Value::is_null) { - para.kinsoku = Some(None); - } -} - -/// The compile error of the first GREP style in `a` whose pattern can't compile. -pub(crate) fn grep_style_error(a: &ParaAttrs) -> Option { - a.grep_styles - .iter() - .flatten() - .filter(|g| !g.pattern.is_empty()) - .find_map(|g| designcraft_compose::grep::Grep::new(&g.pattern, Default::default()).err().map(|e| format!("{e} (pattern {:?})", g.pattern))) -} - -/// Rejects GREP styles a command would set with a pattern that can't compile. -pub(crate) fn check_grep_styles(cmd: &str, a: &ParaAttrs) -> Result<()> { - grep_style_error(a).map_or(Ok(()), |e| Err(bad(cmd, e))) -} - -/// One warning for each paragraph style or paragraph whose GREP style pattern can't compile. -/// Such a GREP style stays in the document and composition skips it. -pub(crate) fn grep_style_warnings(d: &designcraft_doc::Document) -> Vec { - let mut out: Vec = d - .styles - .paragraph - .iter() - .filter_map(|st| grep_style_error(&st.para).map(|e| format!("paragraph style {:?}: GREP style not applied: {e}", st.name))) - .collect(); - for st in d.stories.values() { - for (i, pf) in st.paras.iter().enumerate() { - if let Some(e) = grep_style_error(&pf.para) { - out.push(format!("story {} paragraph {}: GREP style not applied: {e}", st.id.0, i + 1)); - } - } - } - out -} - -#[allow(non_snake_case)] -fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs { - designcraft_doc::ParaProps::common([p]) -} -#[allow(non_snake_case)] -fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { - designcraft_doc::CharProps::common([p]) -} - -fn edit_para(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); - let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; - let rename = str_param(p, "rename").map(str::to_string); - let based = p.get("basedOn").cloned(); - s.edit(|d, _| { - if let Some(Value::String(b)) = &based - && d.styles.para_based_on_cycles(&name, b) - { - return Err(bad("style.paragraph.edit", "based-on would create a cycle")); - } - // A rule object changes only the rule fields it names, over the style's resolved rule. - let (current, _) = d.styles.resolve_para_style(&name); - let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; - continue_numbering(p.get("para"), &mut para); - explicit_default_kinsoku(&mut para, p.get("para")); - check_grep_styles("style.paragraph.edit", ¶)?; - let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; - st.para.merge(¶); - st.chars.merge(&chars); - match based { - Some(Value::String(b)) => st.based_on = Some(b), - Some(Value::Null) => st.based_on = None, - _ => {} - } - if let Some(n) = rename.clone() { - // Every use: other styles, stories, table cells, footnotes, object styles. - rename_style(d, true, &name, &n); - } - ok() - }) -} - -/// Rename a paragraph or character style everywhere it's used. -fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { - if para { - for s in &mut d.styles_mut().paragraph { - if s.name == from { - s.name = to.to_string(); - } - } - } else { - for s in &mut d.styles_mut().character { - if s.name == from { - s.name = to.to_string(); - } - } - } - redirect_style_refs(d, para, from, to); -} - -/// Point every reference to style `from` at `to`, except the style's own registry entry (its -/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which -/// instead removes the entry, after its former uses point at the replacement). -fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { - let rename = |r: &mut String| { - if r == from { - *r = to.to_string(); - } - }; - let st = d.styles_mut(); - // A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent. - let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned(); - if para { - let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); - for s in &mut st.paragraph { - if s.based_on.as_deref() == Some(from) { - s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; - } - if s.next_style.as_deref() == Some(from) { - s.next_style = Some(to.to_string()); - } - } - let objects = st.object.iter_mut().map(|o| &mut o.paragraph_style); - for r in objects.chain(st.cell.iter_mut().map(|c| &mut c.paragraph_style)) { - if r.as_deref() == Some(from) { - *r = Some(to.to_string()); - } - } - rename(&mut st.default_paragraph); - let (fo, eo) = (&mut d.footnote_options, &mut d.endnote_options); - [&mut fo.para_style, &mut eo.para_style, &mut eo.heading_style].into_iter().for_each(rename); - d.toc.iter_mut().flat_map(|t| t.entries.iter_mut().map(|e| &mut e.style)).for_each(rename); - for t in d.toc.iter_mut() { - let formats = t.entries.iter_mut().map(|e| &mut e.entry_style).chain([&mut t.title_style]); - for r in formats.filter(|r| r.as_deref() == Some(from)) { - *r = Some(to.to_string()); - } - } - } else { - let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); - for s in &mut st.character { - if s.based_on.as_deref() == Some(from) { - s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; - } - } - for s in &mut st.paragraph { - rename_char_refs(&mut s.para, from, to); - } - rename(&mut st.default_character); - rename(&mut d.footnote_options.ref_char_style); - } - // The table of contents' character styles ([None] is stored as no style). - if !para && let Some(toc) = d.toc.as_mut() { - for e in &mut toc.entries { - for n in [&mut e.page_number_style, &mut e.separator_style] { - if n.as_deref() == Some(from) { - *n = Some(to.to_string()).filter(|t| t != designcraft_doc::NO_CHAR_STYLE); - } - } - } - } - for sid in d.stories.keys().copied().collect::>() { - let Some(story) = d.story_mut(sid) else { continue }; - story.for_each_text_mut(&mut |st| { - if para { - for f in st.paras.iter_mut().filter(|f| f.style == from) { - f.style = to.to_string(); - } - } else { - for r in st.chars.iter_mut().filter(|r| r.format.style == from) { - r.format.style = to.to_string(); - } - for f in &mut st.paras { - rename_char_refs(&mut f.para, from, to); - } - } - st.rev = st.rev.wrapping_add(1); - }); - } -} - -/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph -/// attributes at a renamed (or deleted, pointed at its replacement) character style. -fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { - let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); - let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); - let names = names.chain(p.drop_cap_style.iter_mut()); - for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) { - if n == from { - *n = to.to_string(); - } - } -} - -fn group_styles(s: &mut Session, p: &Value) -> Result { - let para = match str_param(p, "kind").unwrap_or("paragraph") { - "paragraph" => true, - "character" => false, - k => return Err(bad("style.group", format!("unknown kind `{k}`"))), - }; - let names: Vec = - p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string(); - s.edit(|d, _| { - let mut renamed = serde_json::Map::new(); - for n in &names { - if n.starts_with('[') { - return Err(bad("style.group", format!("`{n}` is built in"))); - } - let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) }; - if !exists { - return Err(bad("style.group", format!("no style `{n}`"))); - } - let base = n.rsplit('/').next().unwrap_or(n); - let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") }; - let taken = |d: &designcraft_doc::Document, x: &str| { - if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) } - }; - if to != *n { - to = Styles::unique_name(|x| taken(d, x), &to); - rename_style(d, para, n, &to); - renamed.insert(n.clone(), json!(to)); - } - } - Ok(json!({"renamed": renamed})) - }) -} - -fn delete_para(s: &mut Session, p: &Value) -> Result { - delete_style(s, p, true) -} - -fn delete_char(s: &mut Session, p: &Value) -> Result { - delete_style(s, p, false) -} - -/// Delete a paragraph (`para`) or character style; everything that used it uses `replaceWith` -/// (default [Basic Paragraph] or [None]) instead, in one undo step. -fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { - let (id, default) = - if para { ("style.paragraph.delete", designcraft_doc::BASIC_PARAGRAPH) } else { ("style.character.delete", designcraft_doc::NO_CHAR_STYLE) }; - let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "missing name"))?.to_string(); - let repl = str_param(p, "replaceWith").unwrap_or(default).to_string(); - if name.starts_with('[') { - return Err(bad(id, "built-in styles can't be deleted")); - } - if repl == name { - return Err(bad(id, "`replaceWith` can't be the deleted style")); - } - s.edit(|d, _| { - let st = &d.styles; - let exists = |n: &str| if para { st.para(n).is_some() } else { n == designcraft_doc::NO_CHAR_STYLE || st.char_style(n).is_some() }; - if !exists(&name) { - return Err(bad(id, format!("no style `{name}`"))); - } - if !exists(&repl) { - return Err(bad(id, format!("no style `{repl}` to replace it with"))); - } - // The deleted style's children are based on the replacement, or on the deleted style's - // own parent when the replacement descends from it (so no based-on chain loops). - let (own_parent, descends) = if para { - (st.para(&name).and_then(|x| x.based_on.clone()), st.para_based_on_cycles(&name, &repl)) - } else { - (st.char_style(&name).and_then(|x| x.based_on.clone()), st.char_based_on_cycles(&name, &repl)) - }; - let parent = if descends { - own_parent.filter(|b| *b != name) - } else if repl == designcraft_doc::NO_CHAR_STYLE || repl == designcraft_doc::NO_PARA_STYLE { - None - } else { - Some(repl.clone()) - }; - let st = d.styles_mut(); - let based = if para { - st.paragraph.retain(|x| x.name != name); - st.paragraph.iter_mut().map(|x| &mut x.based_on).collect::>() - } else { - st.character.retain(|x| x.name != name); - st.character.iter_mut().map(|x| &mut x.based_on).collect() - }; - for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { - *b = parent.clone(); - } - // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. - rename_style(d, para, &name, &repl); - ok() - }) -} - -fn create_char(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); - let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; - if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) - && let Some(target) = super::text::format_targets_pub(s).first() - { - let st = s.doc()?; - // Match paragraph fromSelection's first-character/caret policy, but keep paragraph - // attributes inherited instead of freezing the fully resolved character properties. - let format = super::text::effective_char_format(s, target)?; - let mut captured = st.doc.styles.char_style_attrs(&format.style); - captured.merge(&format.over); - // Replay the explicit fields so null clears a captured value rather than looking absent. - if let Some(overrides) = p.get("chars").and_then(Value::as_object) { - for (key, value) in overrides { - captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?; - } - } - chars = captured; - } - // Based on [None] is based on nothing. - let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); - s.edit(|d, _| { - if let Some(b) = &based_on - && d.styles.char_style(b).is_none() - { - return Err(bad("style.character.create", format!("no character style `{b}`"))); - } - let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base); - d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }); - Ok(json!({"name": name})) - }) -} - -fn edit_char(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); - if name == designcraft_doc::NO_CHAR_STYLE { - return Err(bad("style.character.edit", "[None] can't be edited")); - } - let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; - super::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; - // `null` unsets an attribute (the style no longer sets it). - let unset: Vec = p - .get("chars") - .and_then(Value::as_object) - .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) - .unwrap_or_default(); - let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string()); - let based: Option> = match p.get("basedOn") { - None => None, - Some(Value::Null) => Some(None), - Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None), - Some(Value::String(b)) => Some(Some(b.clone())), - Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")), - }; - s.edit(|d, _| { - if let Some(Some(b)) = &based { - if d.styles.char_style(b).is_none() { - return Err(bad("style.character.edit", format!("no character style `{b}`"))); - } - if d.styles.char_based_on_cycles(&name, b) { - return Err(bad("style.character.edit", "based-on would create a cycle")); - } - } - if let Some(n) = &rename - && (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some()) - { - return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); - } - let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; - st.chars.merge(&chars); - for k in &unset { - st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?; - } - if let Some(b) = based.clone() { - st.based_on = b; - } - if let Some(n) = &rename { - // Every use: other styles, stories, table cells, nested and GREP styles, footnotes. - rename_style(d, false, &name, n); - } - ok() - }) -} - -fn create_swatch(s: &mut Session, p: &Value) -> Result { - if let Some(g) = p.get("gradient") { - return create_gradient_swatch(s, p, g); - } - let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color (or gradient)"))?; - let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?; - let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false); - let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| value_name(color)); - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { - color, - color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }, - }, - locked: false, - named: true, - hidden: false, - }); - Ok(json!({"name": name})) - }) -} - -/// `swatch.create {gradient}`: a named gradient swatch. Stops take a colour swatch by name (as a -/// swatch dragged onto the Gradient panel's stop) or a colour. -fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result { - use designcraft_color::{Gradient, GradientKind, GradientStop}; - const ID: &str = "swatch.create"; - let kind = match g.get("kind").and_then(Value::as_str) { - None | Some("linear") => GradientKind::Linear, - Some("radial") => GradientKind::Radial, - Some(k) => return Err(bad(ID, format!("unknown gradient kind {k}"))), - }; - let doc = &s.doc()?.doc; - let mut stops = Vec::new(); - for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? { - let color = match (st.get("swatch").and_then(Value::as_str), st.get("color")) { - (Some(n), _) => match doc.swatch(n).map(|w| &w.value) { - Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))), - Some(_) => doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no colour")))?, - None => return Err(bad(ID, format!("no swatch named `{n}`"))), - }, - (None, Some(c)) => parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?, - (None, None) => return Err(bad(ID, "a stop needs a swatch or a color")), - }; - let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; - stops.push(GradientStop { - offset: pct("location", 0.0), - color, - opacity: pct("opacity", 100.0), - midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), - }); - } - if stops.len() < 2 { - return Err(bad(ID, "a gradient needs at least two stops")); - } - let mut gradient = Gradient { kind, stops }; - gradient.sort(); - let name = str_param(p, "name").unwrap_or("New Gradient Swatch").to_string(); - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); - d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); - Ok(json!({"name": name})) - }) -} - -/// `swatch.new`: InDesign's New Swatch. An unnamed colour or gradient (the selection's, or -/// `swatch`) is added to the swatches under a name, and stays applied; a named swatch is -/// duplicated as " copy". -fn new_swatch(s: &mut Session, p: &Value) -> Result { - const ID: &str = "swatch.new"; - let st = s.doc()?; - let name = match str_param(p, "swatch") { - Some(n) => n.to_string(), - None => { - let Some(id) = st.selection.items.first().copied() else { - // Nothing selected: a new colour swatch. - return create_swatch(s, &json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); - }; - let it = st.doc.item(id).ok_or_else(|| bad(ID, "no such item"))?; - if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } - } - }; - let w = st.doc.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch named `{name}`")))?; - if matches!(w.value, SwatchValue::None | SwatchValue::Registration) { - return Err(bad(ID, format!("`{name}` can't be made a new swatch"))); - } - if w.hidden { - // An unnamed gradient takes InDesign's "New Gradient Swatch"; an unnamed colour its values. - let new = match w.value { - SwatchValue::Gradient { .. } => Styles::unique_name(|n| st.doc.swatch(n).is_some(), "New Gradient Swatch"), - _ => name.clone(), - }; - return add_to_swatches(s, &json!({"swatch": name, "name": new})); - } - s.edit(|d, _| { - let copy = Styles::unique_name(|n| d.swatch(n).is_some(), &format!("{name} copy")); - d.swatches.push(Swatch { name: copy.clone(), locked: false, named: true, hidden: false, ..w.clone() }); - Ok(json!({"name": copy})) - }) -} - -pub fn parse_color(v: &Value) -> Option { - use designcraft_color::Color; - match v { - Value::String(s) => Color::from_hex(s), - // `{model, …}` is the colour's own form (as `color.convert` takes it): fractions, never guessed. - Value::Object(o) if o.contains_key("model") => model_color(v), - // One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose - // components are all within 0..1 is read as fractions (the older form). - Value::Array(a) if a.len() >= 3 => { - let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; - let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; - Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) - } - // Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't - // mean fractions (#910). - Value::Object(o) => { - if o.contains_key("gray") { - let gray = o.get("gray").and_then(Value::as_f64)?; - return Some(Color::gray(if gray > 1.0 { gray / 100.0 } else { gray } as f32)); - } - let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32); - Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) - } - _ => None, - } -} - -/// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour. -fn model_color(v: &Value) -> Option { - use designcraft_color::Color; - let color: Color = serde_json::from_value(v.clone()).ok()?; - // A float's last bit (100.0 / 100.0 ≠ 1.0 after an f32 round trip) is not out of range. - let unit = |x: f32| x.is_finite() && (-1e-4..=1.0 + 1e-4).contains(&x); - let q = |x: f32| x.clamp(0.0, 1.0); - match color { - Color::Rgb { r, g, b } => [r, g, b].into_iter().all(unit).then(|| Color::rgb(q(r), q(g), q(b))), - Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().all(unit).then(|| Color::cmyk(q(c), q(m), q(y), q(k))), - Color::Gray { k } => unit(k).then_some(Color::Gray { k: q(k) }), - } -} - -fn apply_object(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); - let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; - let ids = super::targets(s, p)?; - s.edit(|d, _| { - let mut stories = Vec::new(); - for id in &ids { - let Some(it) = d.item_mut(*id) else { continue }; - it.object_style = name.clone(); - os.apply_to(it); - if let Some(tf) = it.text_frame() { - stories.push(tf.story); - } - } - if let Some(ps) = &os.paragraph_style { - for sid in stories { - if let Some(st) = d.story_mut(sid) { - let len = st.len(); - st.format_paras(0..len, |f| f.style = ps.clone()); - } - } - } - Ok(Value::Null) - }) -} - -fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { - let ids = super::targets(s, p)?; - // Each target with its style's attributes applied, kept only where that changes the item. - let d = &s.doc()?.doc; - let changed: Vec = ids - .iter() - .filter_map(|id| { - let it = d.item(*id)?; - let mut next = it.clone(); - d.styles.object_style(&it.object_style)?.apply_to(&mut next); - (next != *it).then_some(next) - }) - .collect(); - if changed.is_empty() { - return Ok(json!({"changed": 0})); - } - s.edit(|d, _| { - for next in &changed { - if let Some(it) = d.item_mut(next.id) { - *it = next.clone(); - } - } - Ok(json!({"changed": changed.len()})) - }) -} - -/// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for -/// `effects.`. -const EFFECT_KEYS: [&str; 9] = - ["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"]; - -/// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over -/// `base`, or `None` when `p` names no effect. -fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result> { - let mut patch = serde_json::Map::new(); - match p.get("effects") { - None | Some(Value::Null) => {} - Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), - Some(_) => return Err(bad(cmd, "`effects` must be an object or null")), - } - for k in EFFECT_KEYS { - if let Some(v) = p.get(k) { - patch.insert(k.to_string(), v.clone()); - } - } - if patch.is_empty() { - return Ok(None); - } - let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; - merge_json(&mut v, Value::Object(patch), 0); - let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?; - e.sanitized().map(Some).map_err(|m| bad(cmd, m)) -} - -/// Merges `top` into `base`: objects key by key, anything else replaced. -fn merge_json(base: &mut Value, top: Value, depth: usize) { - match (base, top) { - (Value::Object(b), Value::Object(t)) if depth < 8 => { - for (k, v) in t { - match b.get_mut(&k) { - Some(slot) => merge_json(slot, v, depth + 1), - None => { - b.insert(k, v); - } - } - } - } - (b, t) => *b = t, - } -} - -fn create_object(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); - let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); - let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; - let mut os = ObjectStyle::default(); - if let Some(it) = &src { - os.fill = Some(it.fill.clone()); - os.stroke = Some(it.stroke.clone()); - os.text_frame = it.text_frame().map(|t| t.options.clone()); - os.effects = Some(it.effects.clone()); - } - if let Some(sw) = str_param(p, "fill") { - os.fill = Some(designcraft_doc::Fill::swatch(sw)); - } - if let Some(ps) = str_param(p, "paragraphStyle") { - os.paragraph_style = Some(ps.to_string()); - } - if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { - os.effects = Some(e); - } - s.edit(|d, _| { - let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); - os.name = name.clone(); - d.styles_mut().object.push(os.clone()); - Ok(json!({"name": name})) - }) -} - -fn edit_object(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "style.object.edit"; - let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); - if name == designcraft_doc::NO_OBJECT_STYLE { - return Err(bad(CMD, format!("`{name}` can't be edited"))); - } - let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; - let effects = if p.get("effects").is_some_and(Value::is_null) { - if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { - return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); - } - None - } else { - match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { - Some(e) => Some(e), - None => return Ok(json!({"name": name, "updated": 0})), - } - }; - if effects == old.effects { - return Ok(json!({"name": name, "updated": 0})); - } - s.edit(|d, _| { - let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { - return Err(bad(CMD, format!("no object style `{name}`"))); - }; - os.effects = effects.clone(); - // An item follows the style while its effects are the style's. When the style didn't set - // effects before, only items without effects of their own follow (the default styles are - // on most frames). - let follows = old.effects.clone().unwrap_or_default(); - let updated = match &effects { - Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), - None => 0, - }; - Ok(json!({"name": name, "updated": updated})) - }) -} - -/// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). -fn value_name(color: designcraft_color::Color) -> String { - designcraft_color::swatch::value_name(color) -} - -fn load_swatches(s: &mut Session, p: &Value) -> Result { - let bytes = match (str_param(p, "path"), str_param(p, "base64")) { - (_, Some(b)) => super::file::base64_decode(b), - (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, - _ => return Err(bad("swatch.load", "give `path` or `base64`")), - }; - let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; - let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); - s.edit(|d, _| { - // The position of the first swatch with each name, kept up to date as the file's colors are added. - let mut at: std::collections::HashMap = std::collections::HashMap::new(); - for (i, x) in d.swatches.iter().enumerate() { - at.entry(x.name.clone()).or_insert(i); - } - let mut names = FileNames::default(); - let (mut added, mut replaced) = (0, 0); - for mut w in incoming { - // An unnamed color is named by its value, and a name the file already gave gets a number. - if w.name.is_empty() - && let SwatchValue::Color { color, .. } = w.value - { - w.name = value_name(color); - w.named = false; - } - w.name = names.unique(std::mem::take(&mut w.name)); - match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { - Some(x) if x.locked => {} - Some(x) => { - if replace { - x.value = w.value; - x.hidden = false; - replaced += 1; - } - } - None => { - at.insert(w.name.clone(), d.swatches.len()); - d.swatches.push(w); - added += 1; - } - } - } - Ok(json!({"added": added, "replaced": replaced})) - }) -} - -/// The swatch names one `.ase` file has given. A name already given gets the first free number -/// ("Red 2"), as [`Styles::unique_name`] numbers names. -#[derive(Default)] -struct FileNames { - taken: std::collections::HashSet, - /// The last number tried for each repeated name. - last: std::collections::HashMap, -} - -impl FileNames { - fn unique(&mut self, base: String) -> String { - if self.taken.insert(base.clone()) { - return base; - } - // Each pass tries the next number, and only finitely many names are taken. - let n = self.last.entry(base.clone()).or_insert(1); - loop { - *n += 1; - let name = format!("{base} {n}"); - if self.taken.insert(name.clone()) { - return name; - } - } - } -} - -fn save_swatches(s: &mut Session, p: &Value) -> Result { - let d = &s.doc()?.doc; - let names: Option> = - p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); - let chosen: Vec = d.swatches.iter().filter(|w| names.as_ref().is_none_or(|n| n.contains(&w.name))).cloned().collect(); - let bytes = designcraft_color::ase::write(&chosen); - match str_param(p, "path") { - Some(path) => { - crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; - Ok(json!({"path": path, "bytes": bytes.len()})) - } - None => Ok(json!({"base64": super::file::base64_encode(&bytes)})), - } -} - -fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { - let stops: Vec = g - .stops - .iter() - .map(|s| json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()})) - .collect(); - json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops}) -} - -fn apply_gradient(s: &mut Session, p: &Value) -> Result { - use designcraft_color::{GradientKind, GradientStop}; - let kind = match str_param(p, "kind") { - Some("radial") => Some(GradientKind::Radial), - Some("linear") => Some(GradientKind::Linear), - Some(k) => return Err(bad("object.gradient", format!("unknown kind {k}"))), - None => None, - }; - let stops = match p.get("stops").and_then(Value::as_array) { - Some(a) => { - let doc = &s.doc()?.doc; - let mut v = Vec::new(); - for st in a { - // A swatch dropped on a stop gives it the swatch's colour (a tint's mixed in). - let color = match st.get("swatch").and_then(Value::as_str) { - Some(name) => doc - .resolve_color(name, 1.0) - .filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) - .ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a colour swatch")))?, - None => st.get("color").and_then(parse_color).ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, - }; - let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; - v.push(GradientStop { - offset: pct("location", 0.0), - color, - opacity: pct("opacity", 100.0), - midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), - }); - } - if v.len() < 2 { - return Err(bad("object.gradient", "a gradient needs at least two stops")); - } - Some(v) - } - None => None, - }; - let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); - let angle = p.get("angle").and_then(Value::as_f64); - let stroke = match str_param(p, "target") { - None | Some("fill") => false, - Some("stroke") => true, - Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), - }; - let (from, to) = (super::point_param(p, "from"), super::point_param(p, "to")); - let ids = super::targets(s, p)?; - s.edit(|d, _| { - let mut out = Value::Null; - for id in &ids { - let Some(it) = d.item(*id) else { continue }; - let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; - let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); - let mut g = cur.clone().unwrap_or_default(); - if let Some(k) = kind { - g.kind = k; - } - if let Some(st) = &stops { - g.stops = st.clone(); - g.sort(); - } - if reverse { - g.reverse(); - } - // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch - // or add an unnamed one. - let name = if cur.as_ref() == Some(&g) { - cur_name - } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) { - w.name.clone() - } else { - let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient"); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Gradient { gradient: g.clone() }, - locked: false, - named: false, - hidden: true, - }); - name - }; - let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); - let Some(it) = d.item_mut(*id) else { continue }; - // The fill's or the stroke's paint: swatch, tint, angle, vector. - let (swatch, tint, g_angle, g_vector) = if stroke { - (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) - } else { - (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) - }; - *swatch = name.clone(); - *tint = 1.0; - if let Some(a) = angle { - *g_angle = Some(a); - *g_vector = None; - } - if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { - let (a, b) = (inv * a, inv * b); - if (b - a).hypot() > 1e-6 { - *g_vector = Some([a.x, a.y, b.x, b.y]); - } - } - if out.is_null() { - out = gradient_json(&name, &g); - } - } - Ok(out) - }) -} - -fn apply_color(s: &mut Session, p: &Value) -> Result { - let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; - let stroke = str_param(p, "target") == Some("stroke"); - let named = p.get("ids").is_some() || p.get("id").is_some(); - let text = !named - && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); - // Nothing selected: the colour of new shapes (InDesign's document defaults). - if !text && super::object::nothing_targeted(s, p)? { - return s.edit(|d, _| { - let name = color_swatch(d, color); - let c = &mut d.settings.new_object; - if stroke { - (c.stroke, c.stroke_tint) = (name.clone(), 1.0); - } else { - (c.fill, c.fill_tint) = (name.clone(), 1.0); - } - Ok(json!({"swatch": name, "newObject": true})) - }); - } - let (ids, texts) = if text { (vec![], super::text::format_targets_pub(s)) } else { (super::targets(s, p)?, vec![]) }; - if text && texts.is_empty() { - return Err(bad("object.color", "select the text to colour")); - } - // A caret: the colour is for the next typing (once the swatch exists). - let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); - let r = s.edit(|d, _| { - let name = color_swatch(d, color); - color_items(d, &ids, &name, stroke); - if !caret { - let a = if stroke { - CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } - } else { - CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } - }; - super::text::apply_char_attrs(d, &texts, &a, &[]); - } - Ok(json!({"swatch": name})) - })?; - if caret && let Some(name) = r.get("swatch").cloned() { - let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; - super::text::format_chars(s, &attrs)?; - } - Ok(r) -} - -/// A process swatch holding this colour to the precision its name shows (whole CMYK percents, -/// 0-255 RGB), else a new unnamed one (hidden from the Swatches panel until Add to Swatches): -/// values a fraction of a percent apart would otherwise add hidden "C=0 M=50 Y=100 K=0 2" twins. -fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { - let shown = value_name(color); - let process = |w: &&Swatch| match &w.value { - SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } => Some(*c), - _ => None, - }; - let existing = d - .swatches - .iter() - .find(|w| process(w) == Some(color)) - .or_else(|| { - d.swatches - .iter() - .find(|w| process(w).is_some_and(|c| std::mem::discriminant(&c) == std::mem::discriminant(&color) && value_name(c) == shown)) - }) - .map(|w| w.name.clone()); - if let Some(n) = existing { - return n; - } - let name = Styles::unique_name(|n| d.swatch(n).is_some(), &shown); - d.swatches.push(Swatch { - name: name.clone(), - value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, - locked: false, - named: false, - hidden: true, - }); - name -} - -fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { - for id in ids { - if let Some(it) = d.item_mut(*id) { - if stroke { - it.stroke.swatch = name.to_string(); - it.stroke.tint = 1.0; - } else { - it.fill.swatch = name.to_string(); - it.fill.tint = 1.0; - } - } - } -} - -fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { super::has_selection(s) } -} - -/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, -/// as an unnamed colour, fills the selected text (characters), or the selected objects. -fn sample_color(s: &mut Session, p: &Value) -> Result { - const ID: &str = "color.sample"; - let at = super::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; - let sr = super::spread_param(p, ID)?; - let st = s.doc()?; - let Some((from, color)) = image_color_at(&st.doc, sr, at) else { - // No opaque image pixel there (an empty part of an image frame, a transparent pixel): the - // Eyedropper applies the clicked object's attributes instead. - return match p.get("matchFrom") { - Some(from) => { - let mut params = p.clone(); - params["from"] = from.clone(); - super::object::match_attributes(s, ¶ms) - } - None => Err(bad(ID, "no placed image under the point")), - }; - }; - let hex = color.to_hex(); - let mut params = p.clone(); - params["color"] = json!(hex); - let mut result = apply_color(s, ¶ms)?; - result["color"] = json!(hex); - result["from"] = json!(from.0); - Ok(result) -} - -/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose -/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); -/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). -pub(crate) fn image_color_at( - d: &designcraft_doc::Document, - sr: designcraft_doc::SpreadRef, - at: designcraft_geom::Point, -) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { - let sp = d.spread(sr)?; - let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); - items.sort_by_key(|i| d.layer_rank(i.layer)); - items - .into_iter() - .rev() - .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) - .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) -} - -fn sample_item( - d: &designcraft_doc::Document, - it: &designcraft_doc::Item, - outer: designcraft_geom::Affine, - at: designcraft_geom::Point, - depth: usize, -) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { - use designcraft_doc::Content; - if it.hidden || depth > 64 { - return None; - } - let xf = outer * it.xf; - match &it.content { - // Only the active state of a multi-state object shows. - Content::Group { .. } => { - let shown: Vec<_> = it.shown_children().collect(); - shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)) - } - Content::Graphic(g) => { - let inner = xf.inverse() * at; - if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { - return None; - } - // A clipping path hides the image outside it. - if let Some((region, even_odd)) = g.clip_region() { - let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero }; - if !designcraft_geom::hit::fill_contains(®ion, rule, inner) { - return None; - } - } - let gp = g.xf.inverse() * inner; - let (w, h) = g.size; - // Written so that NaN (a degenerate transform) is outside too. - if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { - return None; - } - let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; - let img = designcraft_images::decode_rgba(&asset.data)?; - let (pw, ph) = img.dimensions(); - let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); - let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); - let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; - (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) - } - _ => None, - } -} - -fn add_to_swatches(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let name = match str_param(p, "swatch") { - Some(n) => n.to_string(), - None => { - let id = st.selection.items.first().copied().ok_or_else(|| bad("swatch.addToSwatches", "select an object or give `swatch`"))?; - let it = st.doc.item(id).ok_or_else(|| bad("swatch.addToSwatches", "no such item"))?; - if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } - } - }; - let rename = str_param(p, "name").map(str::to_string); - let result = s.edit(|d, _| { - let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; - if w.locked { - return Err(bad("swatch.addToSwatches", "special swatches are already listed")); - } - w.hidden = false; - let Some(new) = rename.filter(|n| *n != name) else { return Ok(json!({"name": name})) }; - if d.swatches.iter().any(|w| w.name == new) { - return Err(bad("swatch.addToSwatches", format!("a swatch named `{new}` exists"))); - } - check_text_nesting(d, "swatch.addToSwatches")?; - let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; - w.name = new.clone(); - w.named = true; - d.rename_swatch_refs(&name, &new); - Ok(json!({"name": new})) - })?; - if let Some(new) = result.get("name").and_then(Value::as_str) { - super::text::rename_typing_swatch(s, &name, new); - } - Ok(result) -} - -/// Renaming a swatch walks every text inside a story (table cells, notes): refuse the whole edit -/// for nesting deeper than 32 levels rather than leave deeper references at a removed name. -fn check_text_nesting(d: &designcraft_doc::Document, id: &str) -> Result<()> { - fn within(st: &designcraft_doc::Story, depth: usize) -> bool { - depth <= 32 - && st.tables.values().all(|t| t.cells.iter().all(|c| within(&c.text, depth + 1))) - && st.notes.iter().chain(&st.endnotes).all(|n| within(&n.text, depth + 1)) - } - if d.stories.values().all(|st| within(st, 0)) { Ok(()) } else { Err(bad(id, "story nesting exceeds 32 levels")) } -} - -/// Swatch Options: rename, Name with Color Value, colour type and value, a tint swatch's tint -/// and base — one undo step. Every use of the swatch follows a rename. -fn swatch_options(s: &mut Session, p: &Value) -> Result { - const ID: &str = "swatch.options"; - let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?.to_string(); - let to = str_param(p, "to").map(|n| n.trim().to_string()); - let with_value = p.get("nameWithValue").and_then(Value::as_bool); - let color = match p.get("color") { - Some(c) => Some(parse_color(c).ok_or_else(|| bad(ID, "bad color"))?), - None => None, - }; - let spot = p.get("spot").and_then(Value::as_bool); - let tint = match p.get("tint") { - Some(v) => Some(v.as_f64().filter(|t| t.is_finite() && (0.0..=1.0).contains(t)).ok_or_else(|| bad(ID, "tint is 0–1"))? as f32), - None => None, - }; - let base = str_param(p, "base").map(str::to_string); - let result = s.edit(|d, _| { - let w = d.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; - let mut w2 = w.clone(); - if w.locked { - if to.as_ref().is_some_and(|t| *t != name) || with_value == Some(true) { - return Err(bad(ID, format!("`{name}` can't be renamed"))); - } - match (&mut w2.value, color) { - (_, None) => {} - (SwatchValue::Paper { color: c }, Some(new)) => *c = new, - _ => return Err(bad(ID, format!("`{name}` can't be edited"))), - } - if spot.is_some() || tint.is_some() || base.is_some() { - return Err(bad(ID, format!("`{name}` can't be edited"))); - } - } else { - match &mut w2.value { - SwatchValue::Color { color: c, color_type } => { - if let Some(new) = color { - *c = new; - } - if let Some(sp) = spot { - *color_type = if sp { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }; - } - if tint.is_some() || base.is_some() { - return Err(bad(ID, "only tint swatches have a tint")); - } - } - SwatchValue::Tint { base: b, tint: t } => { - if color.is_some() || spot.is_some() { - return Err(bad(ID, "a tint swatch takes its colour from its base")); - } - if let Some(v) = tint { - *t = v; - } - if let Some(nb) = &base { - if !d.swatch(nb).is_some_and(|x| matches!(x.value, SwatchValue::Color { .. })) || *nb == name { - return Err(bad(ID, format!("`{nb}` isn't a colour swatch"))); - } - *b = nb.clone(); - } - } - _ => { - if color.is_some() || spot.is_some() || tint.is_some() || base.is_some() { - return Err(bad(ID, format!("`{name}` only takes a name here"))); - } - } - } - // Name with Color Value: the name is the colour's values and tracks later edits. - let auto = with_value.unwrap_or(!w.named && to.as_ref().is_none_or(|t| *t == name)); - let derived = match (&w2.value, auto) { - (SwatchValue::Color { color, .. }, true) => Some(value_name(*color)), - _ => None, - }; - match derived { - Some(n) => { - w2.name = n; - w2.named = false; - } - None => { - if let Some(t) = &to { - if t.is_empty() { - return Err(bad(ID, "a swatch needs a name")); - } - w2.name = t.clone(); - } - if with_value == Some(false) || w2.name != name { - w2.named = true; - } - } - } - if w2.name != name && d.swatch(&w2.name).is_some() { - return Err(bad(ID, format!("a swatch named `{}` exists", w2.name))); - } - } - let new = w2.name.clone(); - if new != name { - check_text_nesting(d, ID)?; - } - let slot = d.swatches.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; - *slot = w2; - d.rename_swatch_refs(&name, &new); - Ok(json!({"name": new})) - })?; - if let Some(new) = result.get("name").and_then(Value::as_str) { - super::text::rename_typing_swatch(s, &name, new); - } - Ok(result) -} - -/// #777: the params descriptions name the attributes, and a wrong one is the command's error. #[cfg(test)] -mod attribute_error_tests { - use super::*; - use crate::cmd::find_command; - - #[test] - fn a_wrong_attribute_names_the_command_and_the_right_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string(); - assert_eq!( - err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})), - "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?" - ); - assert_eq!( - err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})), - "invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?" - ); - assert!( - err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}})) - .starts_with("invalid parameters for `style.character.create`: chars:") - ); - assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes")); - s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap(); - assert!( - err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}})) - .starts_with("invalid parameters for `style.paragraph.edit`: chars:") - ); - s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap(); - assert!( - err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}})) - .starts_with("invalid parameters for `style.character.edit`: chars:") - ); - for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { - let doc = find_command(id).unwrap().params; - assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}"); - } - assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align")); - } -} - -#[cfg(test)] -mod color_tests { - use super::*; - - fn fill_session() -> Session { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); - session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); - session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); - session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); - session.execute("type.char", &json!({"size": 18})).unwrap(); - session.execute("edit.undo", &json!({})).unwrap(); - session - } - - fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { - let before = session.doc().unwrap().clone(); - let journal = session.journal.clone(); - let fill = params - .pointer("/chars/fill") - .or_else(|| params.pointer("/attrs/fill")) - .or_else(|| params.get("fill")) - .and_then(Value::as_str) - .unwrap_or("Missing Ink"); - - let result = session.execute(command, ¶ms); - - match result { - Err(crate::EngineError::BadParams { cmd, msg }) => { - assert_eq!(cmd, command); - assert!(msg.contains(fill), "{msg}"); - assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); - } - other => panic!("expected BadParams for {command}, got {other:?}"), - } - let after = session.doc().unwrap(); - assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); - assert_eq!(before.selection, after.selection); - assert_eq!(before.revision, after.revision); - assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); - assert_eq!(journal, session.journal); - } - - #[test] - fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { - for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { - for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { - let mut session = fill_session(); - assert_fill_rejected(&mut session, "type.char", params); - } - } - } - - #[test] - fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { - for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { - for fill in ["Missing Ink", "#ff0000"] { - let mut session = fill_session(); - session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); - session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); - let mut params = json!({"name": "Existing", "chars": {"fill": fill, "size": 24}}); - // Only the edit commands take `rename`. - if command.ends_with(".edit") { - params["rename"] = json!("Renamed"); - } - assert_fill_rejected(&mut session, command, params); - } - } - } - - #[test] - fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { - for params in - [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] - { - let mut session = fill_session(); - let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); - let fill = swatch["name"].as_str().unwrap(); - session.execute("type.char", &json!({"fill": fill})).unwrap(); - session.execute("swatch.delete", &json!({"name": fill})).unwrap(); - assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); - - session.execute("style.paragraph.create", ¶ms).unwrap(); - - let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; - assert_eq!(chars.fill.as_deref(), Some(fill)); - if params.get("chars").is_some() { - assert_eq!(chars.size, Some(24.0)); - } - } - } - - #[test] - fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { - for fill in ["Missing Ink", "#ff0000"] { - let mut session = fill_session(); - - assert_fill_rejected( - &mut session, - "style.paragraph.create", - json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), - ); - } - } - - #[test] - fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { - let mut session = fill_session(); - session - .edit(|document, selection| { - let selected = selection.text.unwrap(); - document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); - Ok(()) - }) - .unwrap(); - - session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); - - assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); - } - - #[test] - fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { - for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { - for fill in ["Missing Ink", "#ff0000"] { - let mut session = fill_session(); - assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); - } - } - } - - #[test] - fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { - for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { - let mut session = fill_session(); - if !fill.starts_with('[') { - let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); - assert_eq!(created["name"], fill); - } - for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { - session.execute("type.char", ¶ms).unwrap(); - assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); - } - for kind in ["paragraph", "character"] { - session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); - session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); - let document = &session.doc().unwrap().doc; - for name in ["Colored", "Existing"] { - let attrs = if kind == "paragraph" { - &document.styles.para(name).unwrap().chars - } else { - &document.styles.char_style(name).unwrap().chars - }; - assert_eq!(attrs.fill.as_deref(), Some(fill)); - } - } - assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); - let selected = session.doc().unwrap().selection.text.unwrap(); - let story = session.doc().unwrap().doc.story(selected.story).unwrap(); - assert_eq!(story.text, "Bye"); - assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); - } - } - - #[test] - fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { - for chars in [json!({"size": 18}), json!({"fill": null})] { - let mut session = fill_session(); - session.execute("type.char", &json!({"attrs": chars})).unwrap(); - for kind in ["paragraph", "character"] { - session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); - session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); - } - assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); - } - } - - #[test] - fn percent_components_at_most_one_stay_percent() { - use designcraft_color::Color; - // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. - let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; - assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); - // Fractions take the colour's own form, with its model. - let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; - assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); - // RGB arrays follow the same rule: [1, 128, 255] is 0..255. - let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; - assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); - } - - /// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1. - #[test] - fn cmyk_percentages_of_one_or_less_stay_percent() { - use designcraft_color::Color; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap(); - s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap(); - let d = &s.doc().unwrap().doc; - let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") }; - assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); - let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); - let text = String::from_utf8_lossy(&pdf); - // Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255). - let fills: Vec> = - text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::>>()).collect(); - let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]); - assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}"); - assert!(!cyan(1.0), "no solid cyan: {fills:?}"); - // A light tint whose components are all 1 or less, through object.color. - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap(); - let d = &s.doc().unwrap().doc; - let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; - let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") }; - assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}"); - } - - #[test] - fn a_colour_with_a_model_is_read_as_fractions_without_guessing() { - use designcraft_color::Color; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - let fill = |s: &Session| { - let d = &s.doc().unwrap().doc; - d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() - }; - // M 1 % from the Color panel: not M 100 % (magenta), though every component is within 0..1. - s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 0.01, "y": 0, "k": 0}})).unwrap(); - let Color::Cmyk { c, m, y, k } = fill(&s) else { panic!("not CMYK") }; - assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); - // R 1 of 255 is a near black, not pure red. - s.execute("object.color", &json!({"color": {"model": "rgb", "r": 1.0 / 255.0, "g": 0, "b": 0}})).unwrap(); - let Color::Rgb { r, g, b } = fill(&s) else { panic!("not RGB") }; - assert!((r - 1.0 / 255.0).abs() < 1e-6 && g == 0.0 && b == 0.0, "got {r} {g} {b}"); - // Out of range or not a model: refused rather than guessed. - assert!(s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 50, "y": 0, "k": 0}})).is_err()); - assert!(s.execute("object.color", &json!({"color": {"model": "hsb", "h": 0, "s": 0, "b": 0}})).is_err()); - } - - #[test] - fn colours_that_round_to_the_same_name_share_one_swatch() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - let n = |s: &Session| s.doc().unwrap().doc.swatches.len(); - let first = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.5, "y": 1, "k": 0}})).unwrap(); - let before = n(&s); - for m in [0.501, 0.499, 0.5041] { - let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": m, "y": 1, "k": 0}})).unwrap(); - assert_eq!(r["swatch"], first["swatch"], "M {m}"); - } - s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5, "g": 0.25, "b": 0}})).unwrap(); - s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5005, "g": 0.25, "b": 0}})).unwrap(); - assert_eq!(n(&s), before + 1, "no hidden twins for values a fraction of a step apart"); - // A whole percent apart is a different colour. - let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.51, "y": 1, "k": 0}})).unwrap(); - assert_eq!(r["swatch"], "C=10 M=51 Y=100 K=0"); - } - - #[test] - fn style_groups_rename_every_use() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); - let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap(); - assert_eq!(r["renamed"]["Head"], "Titles/Head"); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head"); - assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head")); - // Out of the group again. - s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap(); - assert!(s.doc().unwrap().doc.styles.para("Head").is_some()); - assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); - } - - #[test] - fn character_style_edit_sets_unsets_rebases_and_renames_every_use() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap(); - s.execute( - "style.paragraph.create", - &json!({"name": "Lead", "para": { - "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], - "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], - "nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}), - ) - .unwrap(); - s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - - s.execute( - "style.character.edit", - &json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.styles.char_style("Emphasis").is_none()); - let st = d.styles.char_style("Strong").unwrap(); - assert_eq!(st.based_on.as_deref(), Some("Base")); - // Exactly the attributes set: `null` unset the font style, the rest stayed unset. - assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() }); - let story = d.story(designcraft_doc::StoryId(sid)).unwrap(); - assert!(story.chars.iter().any(|r| r.format.style == "Strong")); - assert!(story.chars.iter().all(|r| r.format.style != "Emphasis")); - let lead = d.styles.para("Lead").unwrap(); - assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(d.footnote_options.ref_char_style, "Strong"); - - // Based On [None] (null or the name) clears it. - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); - - // Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None]. - s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); - assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err()); - assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err()); - assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty()); - } - - #[test] - fn continue_numbering_in_a_style_overrides_an_inherited_start_at() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Steps", "para": {"listType": "numbers", "startAt": 5}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "More Steps", "basedOn": "Steps"})).unwrap(); - let start = |s: &Session, name: &str| s.doc().unwrap().doc.styles.resolve_para_style(name).0.start_at; - assert_eq!(start(&s, "More Steps"), Some(5), "inherited"); - s.execute("style.paragraph.edit", &json!({"name": "More Steps", "para": {"startAt": null}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("More Steps").unwrap().para.start_at, Some(None)); - assert_eq!(start(&s, "More Steps"), None, "continues"); - assert_eq!(start(&s, "Steps"), Some(5), "the parent keeps its Start At"); - s.execute("style.paragraph.edit", &json!({"name": "Steps", "para": {"startAt": null}})).unwrap(); - assert_eq!(start(&s, "Steps"), None); - s.execute("style.paragraph.create", &json!({"name": "Fresh", "basedOn": "More Steps", "para": {"start_at": null}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("Fresh").unwrap().para.start_at, Some(None)); - } - - #[test] - fn null_kinsoku_sets_the_default_rules_over_a_based_on_set() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let hard = serde_json::to_value(designcraft_doc::cjk::Kinsoku::named("HardKinsoku").unwrap()).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Base", "para": {"kinsoku": hard}})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Body", "basedOn": "Base", "para": {"kinsoku": hard}})).unwrap(); - s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"kinsoku": null}})).unwrap(); - let styles = s.doc().unwrap().doc.styles.clone(); - assert_eq!(styles.para("Body").unwrap().para.kinsoku, Some(None)); - assert_eq!(styles.resolve_para_style("Body").0.kinsoku, None, "Base's set no longer applies"); - // An edit without the attribute keeps it. - s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"rensuuji": false}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None)); - } - - #[test] - fn new_character_style_checks_its_based_on() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err()); - s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap(); - let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap(); - assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); - } - - #[test] - fn swatches_save_and_load_ase() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#108080"})).unwrap(); - let b64 = s.execute("swatch.save", &json!({"names": ["Brand Teal"]})).unwrap()["base64"].as_str().unwrap().to_string(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!(r["added"], 1); - assert!(s.doc().unwrap().doc.swatch("Brand Teal").is_some()); - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); - assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); - } - - #[test] - fn loading_swatches_matches_the_first_swatch_of_each_name() { - use designcraft_color::{Color, ColorType}; - let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; - let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - // Two swatches share a name, and an unnamed color is hidden. - s.edit(|d, _| { - d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); - Ok(()) - }) - .unwrap(); - let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); - let b64 = crate::cmd::base64_encode(&file); - let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { - s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() - }; - // With `replace`, the file's second "New" is added as "New 2", only the first "Twin" changes, the hidden - // swatch becomes visible, and the locked [Black] stays. - let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(2), Some(2))); - assert_eq!(named(&s, "New"), [(ink(0.4), false)]); - assert_eq!(named(&s, "New 2"), [(ink(0.5), false)]); - assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); - assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); - assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); - // Without it, every name is already there and nothing changes. - let before = s.doc().unwrap().doc.swatches.clone(); - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); - assert_eq!(s.doc().unwrap().doc.swatches, before); - } - - #[test] - fn loading_swatches_numbers_repeated_names_and_names_unnamed_colors() { - use designcraft_color::{Color, ColorType}; - let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; - let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.edit(|d, _| { - d.swatches.push(sw("Kept", 0.1)); - Ok(()) - }) - .unwrap(); - // "Red 2" comes before the second "Red", which takes "Red 3". Two unnamed colors share a value. - let file = designcraft_color::ase::write(&[ - sw("Red", 0.2), - sw("Red 2", 0.3), - sw("Red", 0.4), - sw("", 0.55), - sw("", 0.55), - sw("", 0.65), - sw("Kept", 0.7), - ]); - let b64 = crate::cmd::base64_encode(&file); - let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { - s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.named)).collect() - }; - // Names already in the document are kept. - let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(6), Some(0))); - assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); - assert_eq!(named(&s, "Red 2"), [(ink(0.3), true)]); - assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); - assert_eq!(named(&s, "C=0 M=0 Y=0 K=55"), [(ink(0.55), false)]); - assert_eq!(named(&s, "C=0 M=0 Y=0 K=55 2"), [(ink(0.55), false)]); - assert_eq!(named(&s, "C=0 M=0 Y=0 K=65"), [(ink(0.65), false)]); - assert_eq!(named(&s, "Kept"), [(ink(0.1), true)]); - assert!(named(&s, "").is_empty()); - // With `replace`, the same file again replaces each of its colors once. - let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); - assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(7))); - assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); - assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); - assert_eq!(named(&s, "Kept"), [(ink(0.7), true)]); - } - - #[test] - fn unnamed_colors_then_add_to_swatches() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - let listed = |s: &Session| s.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).count(); - let before = listed(&s); - let r = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); - assert_eq!(r["swatch"], "C=10 M=20 Y=30 K=0"); - assert_eq!(listed(&s), before, "unnamed: not in the Swatches panel"); - // The same colour again reuses it; the stroke target works too. - let n = s.doc().unwrap().doc.swatches.len(); - s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}, "target": "stroke"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.swatches.len(), n); - let d = &s.doc().unwrap().doc; - let it = d.item(designcraft_doc::ItemId(id)).unwrap(); - assert_eq!(it.fill.swatch, it.stroke.swatch); - // Add to Swatches with a name: listed, and the object follows the rename. - s.execute("swatch.addToSwatches", &json!({"name": "Sand"})).unwrap(); - assert_eq!(listed(&s), before + 1); - let d = &s.doc().unwrap().doc; - assert_eq!(d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); - // A colour equal to an existing swatch applies that swatch. - s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); - } - - #[test] - fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { - use std::sync::Arc; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); - s.execute("endnote.insert", &json!({"text": "end"})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); - s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); - let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); - // The same unnamed colour on a cell, the cell's text and the footnote's text. - s.edit(|d, _| { - let st = d.story_mut(sid).unwrap(); - let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); - t.cells[0].fill = old.clone(); - t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); - Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); - Arc::make_mut(&mut st.endnotes[0]).text.chars[0].format.over.underline_color = Some(old.clone()); - Ok(()) - }) - .unwrap(); - s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); - let d = &s.doc().unwrap().doc; - let st = d.story(sid).unwrap(); - assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); - let cell = &st.tables.values().next().unwrap().cells[0]; - assert_eq!(cell.fill, "Sand"); - assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); - assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); - assert_eq!(st.endnotes[0].text.chars[0].format.over.underline_color.as_deref(), Some("Sand")); - d.check().unwrap(); - } -} - -#[cfg(test)] -mod group_sample_tests { - use serde_json::json; - - use crate::Session; - - /// Grouping the sample's table styles keeps its table cells styled (no missing styles). - #[test] - fn grouping_reaches_table_cells() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap(); - s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap(); - let after = s.execute("preflight.run", &json!({})).unwrap(); - assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); - } -} - -#[cfg(test)] -mod color_group_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn color_groups_hold_swatches_and_round_trip() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap(); - assert_eq!(g["name"], "Brand"); - assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top"); - s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap(); - let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone(); - assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]); - let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); - let back = designcraft_idml::import_idml(&bytes).unwrap(); - assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup"); - s.execute("swatch.delete", &json!({"name": b})).unwrap(); - assert_eq!(groups(&s)[0].swatches, [a.as_str()]); - s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap(); - s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap(); - assert!(groups(&s).is_empty()); - assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); - } -} - -#[cfg(test)] -mod ink_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn ink_manager_converts_and_aliases_spots_in_pdf() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for (n, c) in [("Pantone A", [0, 50, 100, 0]), ("Pantone B", [100, 0, 0, 0])] { - s.execute("swatch.create", &json!({"name": n, "spot": true, "color": {"c": c[0], "m": c[1], "y": c[2], "k": c[3]}})).unwrap(); - } - let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); - s.execute("object.fill", &json!({"swatch": "Pantone A", "ids": [id]})).unwrap(); - let pdf = |s: &mut Session| { - let b = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - String::from_utf8_lossy(&super::super::file::base64_decode(&b)).into_owned() - }; - assert!(pdf(&mut s).contains("Pantone"), "a separation"); - s.execute("ink.options", &json!({"ink": "Pantone A", "alias": "Pantone B"})).unwrap(); - let p = pdf(&mut s); - assert!(p.contains("Pantone#20B") || p.contains("Pantone B"), "aliased onto B"); - assert!(!p.contains("Pantone#20A") && !p.contains("/Pantone A")); - s.execute("ink.options", &json!({"allToProcess": true})).unwrap(); - assert!(!pdf(&mut s).contains("Pantone"), "all spots to process"); - let l = s.execute("ink.list", &json!({})).unwrap(); - assert_eq!(l["inks"][0]["alias"], "Pantone B"); - let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - assert!(back.inks.resolve("Pantone A").1, "IDML ConvertToProcess"); - assert!(back.inks.aliases.iter().any(|(a, b)| a == "Pantone A" && b == "Pantone B"), "IDML AliasInkName"); - assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); - } -} - -#[cfg(test)] -mod export_tag_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn export_tagging_shapes_epub_markup() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Chapter Title", "chars": {"size": 24}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap(); - s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap(); - assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err()); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Chapter Title"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 10, "focus": 14})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Key Term"})).unwrap(); - let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); - assert!(html.contains("

Origins

"), "{html}"); - assert!(html.contains("word"), "{html}"); - // Tagged PDF: a heading element and a paragraph element. - let r = s.execute("file.exportPdf", &json!({"tagged": true, "compressImages": false})).unwrap(); - let pdf = super::super::file::base64_decode(r["base64"].as_str().unwrap()); - let text = String::from_utf8_lossy(&pdf); - assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); - assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); - } -} - -#[cfg(test)] -mod cjk_tests { - use super::*; - #[test] - fn cjk_composite_font_command_invalidates_layout_and_undo_restores_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 150], "content": "text", "text": "AB"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let definition = |scale| json!({"name": "Mixed", "entries": [{"family": "Source Serif 4", "relativeSize": scale}]}); - s.execute("style.compositeFont.set", &definition(1.0)).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 2})).unwrap(); - s.execute("type.char", &json!({"fontFamily": "Mixed", "leadingAki": 0.25, "jidori": 4})).unwrap(); - let old = s.cache.get(&s.doc().unwrap().doc, sid, None); - s.execute("style.compositeFont.set", &definition(2.0)).unwrap(); - let new = s.cache.get(&s.doc().unwrap().doc, sid, None); - assert!(!std::sync::Arc::ptr_eq(&old, &new)); - assert!(new.frames[0].lines[0].end_x > old.frames[0].lines[0].end_x); - s.execute("edit.undo", &json!({})).unwrap(); - let restored = s.cache.get(&s.doc().unwrap().doc, sid, None); - assert_eq!(old.frames[0].lines[0].end_x, restored.frames[0].lines[0].end_x); - assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); - } -} - -#[cfg(test)] -mod delete_tests { - use designcraft_doc::{CellStyle, ObjectStyle, StoryId, Toc, TocEntry}; - use serde_json::json; - - use crate::Session; - - /// Is `name` still named anywhere in the document? - fn named(s: &Session, name: &str) -> bool { - serde_json::to_string(&*s.doc().unwrap().doc).unwrap().contains(&format!("\"{name}\"")) - } - - fn char_doc() -> (Session, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"tracking": 20}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Child", "basedOn": "Emphasis"})).unwrap(); - s.execute("style.character.create", &json!({"name": "Strong", "chars": {"size": 14}})).unwrap(); - let refs = json!({ - "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], - "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], - "nestedLineStyles": [{"style": "Emphasis", "lines": 1}], - "dropCapLines": 2, "dropCapChars": 1, "dropCapStyle": "Emphasis"}); - s.execute("style.paragraph.create", &json!({"name": "Lead", "para": refs})).unwrap(); - s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); - let sid = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap()["story"] - .as_u64() - .unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); - // Local paragraph overrides name it too. - s.execute("type.para", &json!({"attrs": refs})).unwrap(); - s.edit(|d, _| { - d.styles_mut().default_character = "Emphasis".into(); - Ok(()) - }) - .unwrap(); - assert!(named(&s, "Emphasis")); - (s, sid) - } - - #[test] - fn deleting_a_character_style_points_every_use_at_the_replacement() { - let (mut s, sid) = char_doc(); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Strong"})).unwrap(); - assert!(!named(&s, "Emphasis"), "no dangling reference"); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Strong")); - assert_eq!(d.story(StoryId(sid)).unwrap().chars[0].format.style, "Strong"); - let lead = &d.styles.para("Lead").unwrap().para; - assert_eq!(lead.nested_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.grep_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); - assert_eq!(lead.drop_cap_style.as_deref(), Some("Strong")); - assert_eq!(d.footnote_options.ref_char_style, "Strong"); - assert_eq!(d.styles.default_character, "Strong"); - // One undo step brings the style and every use back. - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").is_some()); - assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().chars[0].format.style, "Emphasis"); - } - - #[test] - fn deleting_a_character_style_for_none_unsets_based_on() { - let (mut s, _) = char_doc(); - s.execute("style.character.delete", &json!({"name": "Emphasis"})).unwrap(); - assert!(!named(&s, "Emphasis"), "no dangling reference"); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.styles.char_style("Child").unwrap().based_on, None); - assert_eq!(d.footnote_options.ref_char_style, designcraft_doc::NO_CHAR_STYLE); - } - - /// Replacing a style with its own descendant rebases the deleted style's children on its - /// parent, so no based-on chain becomes a cycle. - #[test] - fn replacing_a_style_with_its_child_makes_no_cycle() { - let (mut s, _) = char_doc(); - s.execute("style.character.create", &json!({"name": "Grandchild", "basedOn": "Child"})).unwrap(); - s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Grandchild"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Base")); - assert_eq!(d.styles.char_style("Grandchild").unwrap().based_on.as_deref(), Some("Child")); - assert!(!named(&s, "Emphasis")); - } - - #[test] - fn bad_style_deletes_are_errors_and_change_nothing() { - let (mut s, _) = char_doc(); - s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); - let before = s.doc().unwrap().doc.clone(); - let undo = s.doc().unwrap().history.undo.len(); - for (id, p) in [ - ("style.character.delete", json!({})), - ("style.character.delete", json!({"name": ""})), - ("style.character.delete", json!({"name": "Nope"})), - ("style.character.delete", json!({"name": "[None]"})), - ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Nope"})), - ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Emphasis"})), - ("style.paragraph.delete", json!({})), - ("style.paragraph.delete", json!({"name": ""})), - ("style.paragraph.delete", json!({"name": "Nope"})), - ("style.paragraph.delete", json!({"name": "[Basic Paragraph]"})), - ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Nope"})), - ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Head"})), - ] { - assert!(s.execute(id, &p).is_err(), "{id} {p}"); - } - assert_eq!(*s.doc().unwrap().doc, *before); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - - #[test] - fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { - let mut s = Session::new(); - s.execute("file.newSample", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Table Body", "nextStyle": "Table Body"})).unwrap(); - s.edit(|d, _| { - let story = *d.stories.keys().next().ok_or(crate::EngineError::NoDocument)?; - let st = d.styles_mut(); - st.default_paragraph = "Table Body".into(); - st.object.push(ObjectStyle { name: "Boxed".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); - st.cell.push(CellStyle { name: "Body Cell".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); - d.footnote_options.para_style = "Table Body".into(); - d.endnote_options.para_style = "Table Body".into(); - d.endnote_options.heading_style = "Table Body".into(); - d.toc = Some(Toc { - story, - title: "Contents".into(), - entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], - page_numbers: false, - title_style: Some("Table Body".into()), - }); - Ok(()) - }) - .unwrap(); - assert!(named(&s, "Table Body")); - s.execute("style.paragraph.delete", &json!({"name": "Table Body", "replaceWith": "Body"})).unwrap(); - assert!(!named(&s, "Table Body"), "no dangling reference"); - let d = s.doc().unwrap().doc.clone(); - let sub = d.styles.para("Sub").unwrap(); - assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body"))); - assert_eq!(d.styles.default_paragraph, "Body"); - assert_eq!(d.endnote_options.heading_style, "Body"); - let after = s.execute("preflight.run", &json!({})).unwrap(); - assert!(!after.to_string().contains("Table Body"), "{after}"); - } -} - -#[cfg(test)] -mod swatch_options_tests { - use designcraft_color::SwatchValue; - use designcraft_doc::{ItemId, StoryId}; - use serde_json::json; - - use crate::Session; - - /// A document whose frame, text, styles, tint swatch and colour group all use "Brand". - fn brand_doc() -> (Session, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("swatch.create", &json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 100, "k": 0}})).unwrap(); - s.execute("swatch.create", &json!({"name": "Other", "color": "#108080"})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); - let (id, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - s.execute("object.fill", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); - s.execute("object.stroke", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fill": "Brand"}})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - s.execute("table.select", &json!({"what": "table"})).unwrap(); - s.execute("table.setCell", &json!({"stroke": {"weight": 2, "type": "dashed", "gapColor": "Brand", "edges": ["all"]}})).unwrap(); - s.execute("swatch.newColorGroup", &json!({"name": "Brand Colors", "swatches": ["Brand"]})).unwrap(); - s.execute("style.character.create", &json!({"name": "Accent"})).unwrap(); - s.edit(|d, _| { - d.swatches.push(designcraft_color::Swatch { - name: "Brand 50%".into(), - value: SwatchValue::Tint { base: "Brand".into(), tint: 0.5 }, - locked: false, - named: true, - hidden: false, - }); - let st = d.styles_mut(); - if let Some(p) = st.para_mut(designcraft_doc::BASIC_PARAGRAPH) { - p.chars.underline_color = Some("Brand".into()); - p.para.shading_color = Some("Brand".into()); - p.para.border_gap_color = Some("Brand".into()); - p.chars.ruby_stroke = Some("Brand".into()); - } - if let Some(c) = st.char_style_mut("Accent") { - c.chars.fill = Some("Brand".into()); - c.chars.kenten_fill = Some("Brand".into()); - } - Ok(()) - }) - .unwrap(); - (s, id, sid) - } - - fn uses(s: &Session, id: u64, sid: u64, name: &str) -> Vec { - let d = &s.doc().unwrap().doc; - let it = d.item(ItemId(id)).unwrap(); - let story = d.story(StoryId(sid)).unwrap(); - let basic = d.styles.para(designcraft_doc::BASIC_PARAGRAPH).unwrap(); - vec![ - it.fill.swatch == name, - it.stroke.swatch == name, - story.chars.iter().any(|r| r.format.over.fill.as_deref() == Some(name)), - basic.chars.underline_color.as_deref() == Some(name), - basic.para.shading_color.as_deref() == Some(name), - d.styles.char_style("Accent").unwrap().chars.fill.as_deref() == Some(name), - matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, .. } if base == name), - d.color_groups[0].swatches == [name], - basic.para.border_gap_color.as_deref() == Some(name), - basic.chars.ruby_stroke.as_deref() == Some(name), - d.styles.char_style("Accent").unwrap().chars.kenten_fill.as_deref() == Some(name), - story.tables.values().any(|t| t.cells.iter().all(|c| c.strokes.iter().all(|e| e.gap_color == name))), - ] - } - - #[test] - fn renaming_a_swatch_renames_every_use_in_one_undo_step() { - let (mut s, id, sid) = brand_doc(); - assert!(uses(&s, id, sid, "Brand").iter().all(|u| *u)); - let r = s.execute("swatch.options", &json!({"name": "Brand", "to": "Signal Orange", "spot": true})).unwrap(); - assert_eq!(r["name"], "Signal Orange"); - let d = &s.doc().unwrap().doc; - assert!(d.swatch("Brand").is_none()); - assert!(matches!(d.swatch("Signal Orange").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. })); - assert_eq!(uses(&s, id, sid, "Signal Orange"), vec![true; 12]); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(s.doc().unwrap().doc.swatch("Brand").is_some()); - assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); - } - - #[test] - fn bad_swatch_renames_change_nothing() { - let (mut s, id, sid) = brand_doc(); - let before = s.doc().unwrap().doc.clone(); - for p in [ - json!({"name": "Brand", "to": "Other"}), - json!({"name": "Brand", "to": " "}), - json!({"name": "Brand", "to": "[Paper]"}), - json!({"name": "[Black]", "to": "Ink"}), - json!({"name": "[Registration]", "to": "Reg"}), - json!({"name": "[None]", "to": "Nothing"}), - json!({"name": "[Black]", "color": "#ff0000"}), - json!({"name": "Brand 50%", "color": "#ff0000"}), - json!({"name": "Missing", "to": "X"}), - ] { - assert!(s.execute("swatch.options", &p).is_err(), "{p}"); - } - assert_eq!(s.doc().unwrap().doc.swatches, before.swatches); - assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); - // [Paper]'s colour can change; its name can't. - s.execute("swatch.options", &json!({"name": "[Paper]", "to": "[Paper]", "color": "#fff8e0"})).unwrap(); - } - - #[test] - fn name_with_color_value_tracks_the_colour() { - let (mut s, id, sid) = brand_doc(); - let r = s.execute("swatch.options", &json!({"name": "Brand", "nameWithValue": true, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); - assert_eq!(r["name"], "C=10 M=20 Y=30 K=40"); - assert_eq!(uses(&s, id, sid, "C=10 M=20 Y=30 K=40"), vec![true; 12]); - // Later colour edits keep the name in step until the swatch is named by hand. - let r = s.execute("swatch.options", &json!({"name": "C=10 M=20 Y=30 K=40", "color": {"c": 0, "m": 0, "y": 0, "k": 50}})).unwrap(); - assert_eq!(r["name"], "C=0 M=0 Y=0 K=50"); - let r = s.execute("swatch.options", &json!({"name": "C=0 M=0 Y=0 K=50", "to": "Warm Gray"})).unwrap(); - assert_eq!(r["name"], "Warm Gray"); - assert!(s.doc().unwrap().doc.swatch("Warm Gray").unwrap().named); - } -} - -#[cfg(test)] -mod grep_style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn grep_style_pattern_that_cannot_compile_is_rejected() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); - let bad = json!([{"style": "Keep", "pattern": "(\\d+"}]); - let e = s.execute("style.paragraph.create", &json!({"name": "Broken", "para": {"grepStyles": bad}})).unwrap_err().to_string(); - assert!(e.contains("bad GREP") && e.contains("(\\\\d+"), "{e}"); - assert!(s.doc().unwrap().doc.styles.para("Broken").is_none()); - - // The lookbehind dialect compiles. - let good = json!([{"style": "Keep", "pattern": "(?<=\\s)[aiouwzAIOUWZ]\\s"}]); - s.execute("style.paragraph.create", &json!({"name": "Polish", "para": {"grepStyles": good}})).unwrap(); - let e = s.execute("style.paragraph.edit", &json!({"name": "Polish", "para": {"grepStyles": bad, "spaceAfter": 6}})).unwrap_err().to_string(); - assert!(e.contains("bad GREP"), "{e}"); - let st = s.doc().unwrap().doc.styles.para("Polish").unwrap().clone(); - assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "(?<=\\s)[aiouwzAIOUWZ]\\s"); - assert_eq!(st.para.space_after, None, "nothing changes"); - - // A paragraph override too. - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ala i kot"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - let e = s.execute("type.para", &json!({"grepStyles": bad})).unwrap_err().to_string(); - assert!(e.contains("bad GREP"), "{e}"); - } - - #[test] - fn idml_import_keeps_a_broken_grep_style_and_warns() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Broken"})).unwrap(); - // Written past the command, as another application could. - s.edit(|d, _| { - if let Some(st) = d.styles_mut().para_mut("Broken") { - st.para.grep_styles = Some(vec![designcraft_doc::GrepStyle { style: "Keep".into(), pattern: "[a-".into() }]); - } - Ok(()) - }) - .unwrap(); - let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); - let r = s.execute("file.openIdml", &json!({"base64": b64})).unwrap(); - let warnings = r["warnings"].to_string(); - assert!(warnings.contains("Broken") && warnings.contains("[a-") && warnings.contains("bad GREP"), "{warnings}"); - let st = s.doc().unwrap().doc.styles.para("Broken").unwrap().clone(); - assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "[a-", "the style opens with its pattern"); - } -} - -#[cfg(test)] -mod sample_color_tests { - use designcraft_color::{Color, SwatchValue}; - use designcraft_doc::ItemId; - use serde_json::json; - - use crate::Session; - - /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. - fn session_with_image() -> (Session, u64) { - let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let b64 = super::super::file::base64_encode(&png); - let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); - (s, r["id"].as_u64().unwrap()) - } - - fn swatch_color(s: &Session, name: &str) -> Color { - match &s.doc().unwrap().doc.swatch(name).unwrap().value { - SwatchValue::Color { color, .. } => *color, - other => panic!("{other:?}"), - } - } - - #[test] - fn the_eyedropper_colours_selected_text_with_the_image_under_it() { - let (mut s, image) = session_with_image(); - let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); - let fid = ItemId(f["id"].as_u64().unwrap()); - let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); - assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); - let name = r["swatch"].as_str().unwrap().to_string(); - assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); - assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); - let st = s.doc().unwrap().doc.story(story).unwrap().clone(); - assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); - assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); - // The frame itself is not coloured. - assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); - } - - #[test] - fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { - let (mut s, _) = session_with_image(); - let lid = s.doc().unwrap().doc.layers[0].id.0; - s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); - s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); - let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); - assert_eq!(r["color"], "#ff0000", "{r}"); - let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); - assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); - // Sampling the same colour again reuses its swatch. - let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); - assert_eq!(again["swatch"], r["swatch"]); - } - - #[test] - fn a_caret_takes_the_sample_for_typing_and_nothing_to_sample_is_refused() { - let (mut s, _) = session_with_image(); - let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); - let fid = ItemId(f["id"].as_u64().unwrap()); - let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); - // A caret: the sampled colour is for the next typing. - let r = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], r["swatch"]); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); - s.execute("text.insert", &json!({"text": "X"})).unwrap(); - let st = s.doc().unwrap().doc.story(story).unwrap().clone(); - assert_eq!(st.format_after(1).over.fill.as_deref(), r["swatch"].as_str(), "{r}"); - assert_eq!(st.format_after(0).over.fill, None, "the text before the caret keeps its colour"); - let undo = s.doc().unwrap().history.undo.len(); - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); - let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); - assert!(none.contains("no placed image"), "{none}"); - // Just past the image's right edge. - assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); - assert_eq!(s.doc().unwrap().history.undo.len(), undo); - } - - #[test] - fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() { - let (mut s, image) = session_with_image(); - let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap(); - // The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only. - s.edit(|d, _| { - let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?; - let b = it.inner_bounds(); - it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0)); - let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) }; - let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1)); - g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left))); - Ok(()) - }) - .unwrap(); - s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); - let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap(); - assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled"); - // The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the - // frame's attributes apply. - for at in [[180, 100], [100, 180]] { - assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}"); - s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap(); - s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap(); - assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}"); - } - } - - #[test] - fn sampling_and_explicit_colour_use_the_same_text_application_path() { - for command in ["color.sample", "object.color"] { - // Each command gets its own key: the eyedropper samples at `at`, object.color takes `color`. - let params = |at: [f64; 2], color: &str, extra: serde_json::Value| { - let mut p = if command == "color.sample" { json!({"at": at}) } else { json!({"color": color}) }; - if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { - p.extend(extra.clone()); - } - p - }; - let (mut s, _) = session_with_image(); - let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); - let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); - let undo = s.doc().unwrap().history.undo.len(); - let r = s.execute(command, ¶ms([100.0, 100.0], "#ff0000", json!({}))).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); - s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); - let text = s.doc().unwrap().doc.story(story).unwrap(); - assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); - // Explicit ids override text targeting, including with a text selection active. - let fid = ItemId(f["id"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); - let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"id": fid.0}))).unwrap(); - assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); - let text = s.doc().unwrap().doc.story(story).unwrap(); - assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); - let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"target": "stroke"}))).unwrap(); - let text = s.doc().unwrap().doc.story(story).unwrap(); - assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); - } - } - - #[test] - fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { - let (mut s, _) = session_with_image(); - let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); - s.execute("selection.set", &json!({"ids": [line]})).unwrap(); - let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); - let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); - assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); - assert!(it.fill.is_none(), "the fill stays empty"); - } -} - -#[cfg(test)] -mod radial_gradient_tests { - use serde_json::json; - - /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on - /// a 200×240 page, rendered at 1 px/pt. - fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { - (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) - } - - #[test] - fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); - let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); - s.execute( - "object.gradient", - &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let mut r = designcraft_render::Renderer::new(); - let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); - // The PDF, placed back and drawn: the same ellipse. - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) - .unwrap(); - let d = p.doc().unwrap().doc.clone(); - let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); - } - - #[test] - fn a_box_with_no_height_or_width_has_a_circle_across_its_length() { - let f = designcraft_doc::Fill::swatch("g"); - let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0); - let (c, rx, ry) = f.radial_ellipse(line); - assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0)); - let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0)); - assert_eq!((rx, ry), (25.0, 25.0)); - // A point: a tiny circle, never a zero radius. - let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0)); - assert!(rx > 0.0 && rx == ry); - // A box keeps its ellipse. - let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)); - assert_eq!((rx, ry), (50.0, 100.0)); - } -} - -#[cfg(test)] -mod gradient_swatch_tests { - use designcraft_color::SwatchValue; - use serde_json::json; - - fn doc() -> crate::Session { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s - } - - #[test] - fn a_gradient_swatch_from_colour_swatches() { - let mut s = doc(); - // As the Gradient panel's stops take swatches dragged onto them. - let r = s - .execute( - "swatch.create", - &json!({"gradient": {"stops": [{"location": 100, "swatch": "C=100 M=0 Y=0 K=0"}, {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}]}}), - ) - .unwrap(); - assert_eq!(r["name"], "New Gradient Swatch"); - let d = &s.doc().unwrap().doc; - let w = d.swatch("New Gradient Swatch").unwrap(); - assert!(w.named && !w.hidden); - let SwatchValue::Gradient { gradient } = &w.value else { panic!("{w:?}") }; - assert_eq!(gradient.stops[0].color, d.resolve_color("C=0 M=100 Y=0 K=0", 1.0).unwrap(), "sorted: magenta first"); - assert_eq!(gradient.stops[1].color, d.resolve_color("C=100 M=0 Y=0 K=0", 1.0).unwrap()); - // A second one gets its own name; bad stops are refused. - let r = s - .execute( - "swatch.create", - &json!({"gradient": {"kind": "radial", "stops": [{"color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}}), - ) - .unwrap(); - assert_eq!(r["name"], "New Gradient Swatch 2"); - for bad in [ - json!({"stops": [{"swatch": "C=0 M=100 Y=0 K=0"}]}), - json!({"stops": [{"swatch": "Nope"}, {"swatch": "[Black]"}]}), - json!({"stops": [{"swatch": "New Gradient Swatch"}, {"swatch": "[Black]"}]}), - json!({"kind": "conic", "stops": [{"color": "#000000"}, {"color": "#ffffff"}]}), - ] { - assert!(s.execute("swatch.create", &json!({"gradient": bad})).is_err(), "{bad}"); - } - } - - #[test] - fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() { - let mut s = doc(); - let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); - // A gradient made in the Gradient panel is unnamed (and not listed) … - s.execute("object.gradient", &json!({"ids": [id], "stops": [{"location": 0, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}]})).unwrap(); - let fill = |s: &crate::Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); - assert!(s.doc().unwrap().doc.swatch(&fill(&s)).unwrap().hidden); - // … until New Swatch: "New Gradient Swatch", listed, still the object's fill. - s.execute("selection.set", &json!({"ids": [id]})).unwrap(); - assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch"); - assert_eq!(fill(&s), "New Gradient Swatch"); - let w = s.doc().unwrap().doc.swatch("New Gradient Swatch").unwrap().clone(); - assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Gradient { .. })); - // A named swatch: a copy. - assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch copy"); - assert_eq!(s.execute("swatch.new", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy"); - assert!(s.execute("swatch.new", &json!({"swatch": "[None]"})).is_err()); - // The stroke in front: the stroke's swatch. - s.execute("object.stroke", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); - assert_eq!(s.execute("swatch.new", &json!({"target": "stroke"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy 2"); - // Nothing selected, no swatch given: a new colour swatch. - s.execute("edit.deselectAll", &json!({})).unwrap(); - let n = s.execute("swatch.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); - let w = s.doc().unwrap().doc.swatch(&n).unwrap(); - assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Color { .. }), "{w:?}"); - } -} - -#[cfg(test)] -mod gradient_stroke_tests { - use serde_json::json; - - /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. - fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { - (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) - } - - #[test] - fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); - let r = s - .execute( - "object.gradient", - &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let it = d.item(designcraft_doc::ItemId(id)).unwrap(); - assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); - assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); - let mut rr = designcraft_render::Renderer::new(); - let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); - // The PDF, placed back and drawn: the same. - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200})) - .unwrap(); - let pd = p.doc().unwrap().doc.clone(); - let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); - // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. - s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); - assert!(a > 215 && b < 40, "dragged: {a} {b}"); - assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); - } - - /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. - fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); - s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); - let mut g = g; - g["ids"] = json!([id]); - g["target"] = json!("stroke"); - s.execute("object.gradient", &g).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); - let pd = p.doc().unwrap().doc.clone(); - let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); - (screen, placed) - } - - #[test] - fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { - // The line's box has no height: the gradient is a circle across the line's length. - let (screen, pdf) = line_with_stroke_gradient( - json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), - ); - for (what, img) in [("screen", &screen), ("pdf", &pdf)] { - let (a, m, b) = along(img); - assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); - } - } - - #[test] - fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { - let (screen, pdf) = - line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); - let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); - // The line's box has no height: the gradient runs across the line's length, so the stroke - // shows its middle. - assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); - assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); - } -} +mod color_tests; #[cfg(test)] #[path = "text_color_tests.rs"] diff --git a/crates/engine/src/cmd/style/color.rs b/crates/engine/src/cmd/style/color.rs new file mode 100644 index 000000000..e212bd3b6 --- /dev/null +++ b/crates/engine/src/cmd/style/color.rs @@ -0,0 +1,332 @@ +use super::swatches::{parse_color, value_name}; +use super::{CharAttrs, Result, Session, Styles, Swatch, SwatchValue, Value, bad, has_doc, json, str_param}; + +fn gradient_json(name: &str, g: &designcraft_color::Gradient) -> Value { + let stops: Vec = g + .stops + .iter() + .map(|s| { + let mut v = json!({"location": (s.offset as f64 * 1000.0).round() / 10.0, "color": s.color.to_hex(), "opacity": (s.opacity * 100.0).round(), "midpoint": (s.midpoint * 100.0).round()}); + if let (Some(o), Some(w)) = (v.as_object_mut(), &s.swatch) { + o.insert("swatch".into(), json!(w)); + } + v + }) + .collect(); + json!({"swatch": name, "kind": if g.kind == designcraft_color::GradientKind::Radial { "radial" } else { "linear" }, "stops": stops}) +} + +pub(super) fn apply_gradient(s: &mut Session, p: &Value) -> Result { + use designcraft_color::{GradientKind, GradientStop}; + let kind = match str_param(p, "kind") { + Some("radial") => Some(GradientKind::Radial), + Some("linear") => Some(GradientKind::Linear), + Some(k) => return Err(bad("object.gradient", format!("unknown kind {k}"))), + None => None, + }; + let stops = match p.get("stops").and_then(Value::as_array) { + Some(a) => { + let doc = &s.doc()?.doc; + let mut v = Vec::new(); + for st in a { + // A swatch dropped on a stop gives it the swatch's color (a tint's mixed in) and + // its inks for print. + // A stop naming a swatch that's gone keeps the color given with it. + let color = st.get("color").and_then(parse_color); + let (color, swatch) = match st.get("swatch").and_then(Value::as_str) { + Some(name) => { + match doc.resolve_color(name, 1.0).filter(|_| designcraft_color::swatch::resolve_gradient(&doc.swatches, name).is_none()) { + Some(c) => (c, Some(name.to_string())), + None => (color.ok_or_else(|| bad("object.gradient", format!("`{name}` isn't a color swatch")))?, None), + } + } + None => (color.ok_or_else(|| bad("object.gradient", "a stop needs a color or a swatch"))?, None), + }; + let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; + v.push(GradientStop { + offset: pct("location", 0.0), + color, + opacity: pct("opacity", 100.0), + midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), + swatch, + }); + } + if v.len() < 2 { + return Err(bad("object.gradient", "a gradient needs at least two stops")); + } + Some(v) + } + None => None, + }; + let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false); + let angle = p.get("angle").and_then(Value::as_f64); + let stroke = match str_param(p, "target") { + None | Some("fill") => false, + Some("stroke") => true, + Some(t) => return Err(bad("object.gradient", format!("unknown target {t}"))), + }; + let (from, to) = (crate::cmd::point_param(p, "from"), crate::cmd::point_param(p, "to")); + let ids = crate::cmd::targets(s, p)?; + s.edit(|d, _| { + let mut out = Value::Null; + for id in &ids { + let Some(it) = d.item(*id) else { continue }; + let cur_name = if stroke { it.stroke.swatch.clone() } else { it.fill.swatch.clone() }; + let cur = designcraft_color::swatch::resolve_gradient(&d.swatches, &cur_name).cloned(); + let mut g = cur.clone().unwrap_or_default(); + if let Some(k) = kind { + g.kind = k; + } + if let Some(st) = &stops { + g.stops = st.clone(); + g.sort(); + } + if reverse { + g.reverse(); + } + // Keep the swatch while the gradient is unchanged, else reuse an equal gradient swatch + // or add an unnamed one. + let name = if cur.as_ref() == Some(&g) { + cur_name + } else if let Some(w) = d.swatches.iter().find(|w| matches!(&w.value, SwatchValue::Gradient { gradient } if *gradient == g)) { + w.name.clone() + } else { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), "Gradient"); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Gradient { gradient: g.clone() }, + locked: false, + named: false, + hidden: true, + }); + name + }; + let inv = d.find(*id).map(|l| d.parent_xf(&l)).zip(d.item(*id).map(|i| i.xf)).map(|(pxf, xf)| (pxf * xf).inverse()); + let Some(it) = d.item_mut(*id) else { continue }; + // The fill's or the stroke's paint: swatch, tint, angle, vector. + let (swatch, tint, g_angle, g_vector) = if stroke { + (&mut it.stroke.swatch, &mut it.stroke.tint, &mut it.stroke.gradient_angle, &mut it.stroke.gradient_vector) + } else { + (&mut it.fill.swatch, &mut it.fill.tint, &mut it.fill.gradient_angle, &mut it.fill.gradient_vector) + }; + *swatch = name.clone(); + *tint = 1.0; + if let Some(a) = angle { + *g_angle = Some(a); + *g_vector = None; + } + if let (Some(a), Some(b), Some(inv)) = (from, to, inv) { + let (a, b) = (inv * a, inv * b); + if (b - a).hypot() > 1e-6 { + *g_vector = Some([a.x, a.y, b.x, b.y]); + } + } + if out.is_null() { + out = gradient_json(&name, &g); + } + } + Ok(out) + }) +} + +pub(super) fn apply_color(s: &mut Session, p: &Value) -> Result { + let color = p.get("color").and_then(parse_color).ok_or_else(|| bad("object.color", "missing or bad color"))?; + let stroke = str_param(p, "target") == Some("stroke"); + let named = p.get("ids").is_some() || p.get("id").is_some(); + let text = !named + && (p.get("text").and_then(Value::as_bool).unwrap_or(false) || s.doc()?.selection.text.is_some() || s.doc()?.selection.cells.is_some()); + // Nothing selected: the colour of new shapes (InDesign's document defaults). + if !text && crate::cmd::object::nothing_targeted(s, p)? { + return s.edit(|d, _| { + let name = color_swatch(d, color); + let c = &mut d.settings.new_object; + if stroke { + (c.stroke, c.stroke_tint) = (name.clone(), 1.0); + } else { + (c.fill, c.fill_tint) = (name.clone(), 1.0); + } + Ok(json!({"swatch": name, "newObject": true})) + }); + } + let (ids, texts) = if text { (vec![], crate::cmd::text::format_targets_pub(s)) } else { (crate::cmd::targets(s, p)?, vec![]) }; + if text && texts.is_empty() { + return Err(bad("object.color", "select the text to colour")); + } + // A caret: the colour is for the next typing (once the swatch exists). + let caret = matches!(texts.as_slice(), [t] if t.range.is_empty()); + let r = s.edit(|d, _| { + let name = color_swatch(d, color); + color_items(d, &ids, &name, stroke); + if !caret { + let a = if stroke { + CharAttrs { stroke: Some(name.clone()), stroke_tint: Some(1.0), ..Default::default() } + } else { + CharAttrs { fill: Some(name.clone()), fill_tint: Some(1.0), ..Default::default() } + }; + crate::cmd::text::apply_char_attrs(d, &texts, &a, &[]); + } + Ok(json!({"swatch": name})) + })?; + if caret && let Some(name) = r.get("swatch").cloned() { + let attrs = if stroke { json!({"stroke": name, "strokeTint": 1.0}) } else { json!({"fill": name, "fillTint": 1.0}) }; + crate::cmd::text::format_chars(s, &attrs)?; + } + Ok(r) +} + +/// A process swatch holding this colour to the precision its name shows (whole CMYK percents, +/// 0-255 RGB), else a new unnamed one (hidden from the Swatches panel until Add to Swatches): +/// values a fraction of a percent apart would otherwise add hidden "C=0 M=50 Y=100 K=0 2" twins. +fn color_swatch(d: &mut designcraft_doc::Document, color: designcraft_color::Color) -> String { + let shown = value_name(color); + let process = |w: &&Swatch| match &w.value { + SwatchValue::Color { color: c, color_type: designcraft_color::ColorType::Process } => Some(*c), + _ => None, + }; + let existing = d + .swatches + .iter() + .find(|w| process(w) == Some(color)) + .or_else(|| { + d.swatches + .iter() + .find(|w| process(w).is_some_and(|c| std::mem::discriminant(&c) == std::mem::discriminant(&color) && value_name(c) == shown)) + }) + .map(|w| w.name.clone()); + if let Some(n) = existing { + return n; + } + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &shown); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { color, color_type: designcraft_color::ColorType::Process }, + locked: false, + named: false, + hidden: true, + }); + name +} + +fn color_items(d: &mut designcraft_doc::Document, ids: &[designcraft_doc::ItemId], name: &str, stroke: bool) { + for id in ids { + if let Some(it) = d.item_mut(*id) { + if stroke { + it.stroke.swatch = name.to_string(); + it.stroke.tint = 1.0; + } else { + it.fill.swatch = name.to_string(); + it.fill.tint = 1.0; + } + } + } +} + +pub(super) fn has_text_or_selection(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some() || d.selection.cells.is_some()) { Ok(()) } else { crate::cmd::has_selection(s) } +} + +/// The Eyedropper on a placed image (InDesign samples the pixel under the pointer): that colour, +/// as an unnamed colour, fills the selected text (characters), or the selected objects. +pub(super) fn sample_color(s: &mut Session, p: &Value) -> Result { + const ID: &str = "color.sample"; + let at = crate::cmd::point_param(p, "at").ok_or_else(|| bad(ID, "missing at"))?; + let sr = crate::cmd::spread_param(p, ID)?; + let st = s.doc()?; + let Some((from, color)) = image_color_at(&st.doc, sr, at) else { + // No opaque image pixel there (an empty part of an image frame, a transparent pixel): the + // Eyedropper applies the clicked object's attributes instead. + return match p.get("matchFrom") { + Some(from) => { + let mut params = p.clone(); + params["from"] = from.clone(); + crate::cmd::object::match_attributes(s, ¶ms) + } + None => Err(bad(ID, "no placed image under the point")), + }; + }; + let hex = color.to_hex(); + let mut params = p.clone(); + params["color"] = json!(hex); + let mut result = apply_color(s, ¶ms)?; + result["color"] = json!(hex); + result["from"] = json!(from.0); + Ok(result) +} + +/// The colour of the placed raster image at `at` on spread `sr`: the frontmost visible image whose +/// frame shows that point (locked ones too, as sampling changes nothing). (image frame, colour); +/// None over no image, a transparent pixel, or a graphic that is not a raster (PDF, EPS). +pub(crate) fn image_color_at( + d: &designcraft_doc::Document, + sr: designcraft_doc::SpreadRef, + at: designcraft_geom::Point, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + let sp = d.spread(sr)?; + let mut items: Vec<&designcraft_doc::Item> = sp.items.iter().map(|i| &**i).collect(); + items.sort_by_key(|i| d.layer_rank(i.layer)); + items + .into_iter() + .rev() + .filter(|it| d.layer(it.layer).is_none_or(|l| l.visible)) + .find_map(|it| sample_item(d, it, designcraft_geom::Affine::IDENTITY, at, 0)) +} + +fn sample_item( + d: &designcraft_doc::Document, + it: &designcraft_doc::Item, + outer: designcraft_geom::Affine, + at: designcraft_geom::Point, + depth: usize, +) -> Option<(designcraft_doc::ItemId, designcraft_color::Color)> { + use designcraft_doc::Content; + if it.hidden || depth > 64 { + return None; + } + let xf = outer * it.xf; + match &it.content { + // Only the active state of a multi-state object shows. + Content::Group { .. } => { + let shown: Vec<_> = it.shown_children().collect(); + shown.into_iter().rev().find_map(|c| sample_item(d, c, xf, at, depth + 1)) + } + Content::Graphic(g) => { + let inner = xf.inverse() * at; + if !designcraft_geom::hit::fill_contains(&it.path.to_bezpath(), designcraft_geom::FillRule::NonZero, inner) { + return None; + } + // A clipping path hides the image outside it. + if let Some((region, even_odd)) = g.clip_region() { + let rule = if even_odd { designcraft_geom::FillRule::EvenOdd } else { designcraft_geom::FillRule::NonZero }; + if !designcraft_geom::hit::fill_contains(®ion, rule, inner) { + return None; + } + } + let gp = g.xf.inverse() * inner; + let (w, h) = g.size; + // Written so that NaN (a degenerate transform) is outside too. + if !(w > 0.0 && h > 0.0 && gp.x >= 0.0 && gp.y >= 0.0 && gp.x < w && gp.y < h) { + return None; + } + let asset = d.assets.get(&g.asset).filter(|a| a.is_raster())?; + let img = designcraft_images::decode_rgba(&asset.data)?; + let (pw, ph) = img.dimensions(); + let px = ((gp.x / w * f64::from(pw)) as u32).min(pw.checked_sub(1)?); + let py = ((gp.y / h * f64::from(ph)) as u32).min(ph.checked_sub(1)?); + let [r, gr, b, a] = img.get_pixel_checked(px, py)?.0; + (a > 0).then(|| (it.id, designcraft_color::Color::rgb8(r, gr, b))) + } + _ => None, + } +} + +/// Renaming a swatch walks every text inside a story (table cells, notes): refuse the whole edit +/// for nesting deeper than 32 levels rather than leave deeper references at a removed name. +pub(super) fn check_text_nesting(d: &designcraft_doc::Document, id: &str) -> Result<()> { + fn within(st: &designcraft_doc::Story, depth: usize) -> bool { + depth <= 32 + && st.tables.values().all(|t| t.cells.iter().all(|c| within(&c.text, depth + 1))) + && st.notes.iter().chain(&st.endnotes).all(|n| within(&n.text, depth + 1)) + } + if d.stories.values().all(|st| within(st, 0)) { Ok(()) } else { Err(bad(id, "story nesting exceeds 32 levels")) } +} diff --git a/crates/engine/src/cmd/style/color_tests.rs b/crates/engine/src/cmd/style/color_tests.rs new file mode 100644 index 000000000..499f985d2 --- /dev/null +++ b/crates/engine/src/cmd/style/color_tests.rs @@ -0,0 +1,533 @@ +use super::*; + +fn fill_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let frame = session.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hi"})).unwrap(); + session.execute("text.select", &json!({"story": frame["story"], "anchor": 0, "focus": 2})).unwrap(); + session.execute("style.paragraph.create", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.create", &json!({"name": "Existing"})).unwrap(); + session.execute("type.char", &json!({"size": 18})).unwrap(); + session.execute("edit.undo", &json!({})).unwrap(); + session +} + +fn assert_fill_rejected(session: &mut Session, command: &str, params: Value) { + let before = session.doc().unwrap().clone(); + let journal = session.journal.clone(); + let fill = params + .pointer("/chars/fill") + .or_else(|| params.pointer("/attrs/fill")) + .or_else(|| params.get("fill")) + .and_then(Value::as_str) + .unwrap_or("Missing Ink"); + + let result = session.execute(command, ¶ms); + + match result { + Err(crate::EngineError::BadParams { cmd, msg }) => { + assert_eq!(cmd, command); + assert!(msg.contains(fill), "{msg}"); + assert!(msg.contains("swatch.create") && msg.contains("returned name"), "{msg}"); + } + other => panic!("expected BadParams for {command}, got {other:?}"), + } + let after = session.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before.doc, &after.doc)); + assert_eq!(before.selection, after.selection); + assert_eq!(before.revision, after.revision); + assert_eq!(format!("{:?}", before.history), format!("{:?}", after.history)); + assert_eq!(journal, session.journal); +} + +#[test] +fn char_fill_rejects_unknown_flat_and_nested_names_when_formatting() { + for fill in ["Missing Ink", "#ff0000", "[black]", " [Black]"] { + for params in [json!({"fill": fill, "size": 24}), json!({"attrs": {"fill": fill, "size": 24}})] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "type.char", params); + } + } +} + +#[test] +fn char_fill_rejects_unknown_names_when_creating_or_editing_styles() { + for command in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + session.execute("style.paragraph.apply", &json!({"name": "Existing"})).unwrap(); + session.execute("style.character.apply", &json!({"name": "Existing"})).unwrap(); + let mut params = json!({"name": "Existing", "chars": {"fill": fill, "size": 24}}); + // Only the edit commands take `rename`. + if command.ends_with(".edit") { + params["rename"] = json!("Renamed"); + } + assert_fill_rejected(&mut session, command, params); + } + } +} + +#[test] +fn char_fill_preserves_deleted_swatch_when_creating_from_selection() { + for params in [json!({"name": "Inherited", "fromSelection": true}), json!({"name": "Inherited", "fromSelection": true, "chars": {"size": 24}})] { + let mut session = fill_session(); + let swatch = session.execute("swatch.create", &json!({"name": "Deleted Ink", "color": "#108080"})).unwrap(); + let fill = swatch["name"].as_str().unwrap(); + session.execute("type.char", &json!({"fill": fill})).unwrap(); + session.execute("swatch.delete", &json!({"name": fill})).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + + session.execute("style.paragraph.create", ¶ms).unwrap(); + + let chars = &session.doc().unwrap().doc.styles.para("Inherited").unwrap().chars; + assert_eq!(chars.fill.as_deref(), Some(fill)); + if params.get("chars").is_some() { + assert_eq!(chars.size, Some(24.0)); + } + } +} + +#[test] +fn char_fill_rejects_explicit_unknown_name_when_creating_from_selection() { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + + assert_fill_rejected( + &mut session, + "style.paragraph.create", + json!({"name": "Explicit", "fromSelection": true, "chars": {"fill": fill, "size": 24}}), + ); + } +} + +#[test] +fn char_fill_accepts_valid_override_when_selection_has_unknown_name() { + let mut session = fill_session(); + session + .edit(|document, selection| { + let selected = selection.text.unwrap(); + document.story_mut(selected.story).unwrap().format_chars(selected.range(), |format| format.over.fill = Some("Missing Ink".into())); + Ok(()) + }) + .unwrap(); + + session.execute("style.paragraph.create", &json!({"name": "Override", "fromSelection": true, "chars": {"fill": "[Paper]"}})).unwrap(); + + assert_eq!(session.doc().unwrap().doc.styles.para("Override").unwrap().chars.fill.as_deref(), Some("[Paper]")); +} + +#[test] +fn char_fill_rejects_unknown_name_when_changing_text_even_without_output() { + for (find, change) in [("Hi", "Bye"), ("Hi", ""), ("Absent", "Bye")] { + for fill in ["Missing Ink", "#ff0000"] { + let mut session = fill_session(); + assert_fill_rejected(&mut session, "find.change", json!({"find": find, "change": change, "attrs": {"fill": fill}})); + } + } +} + +#[test] +fn char_fill_preserves_exact_registered_names_when_used_at_all_boundaries() { + for fill in ["Brand Ink", "#ff0000", "[None]", "[Black]"] { + let mut session = fill_session(); + if !fill.starts_with('[') { + let created = session.execute("swatch.create", &json!({"name": fill, "color": "#108080"})).unwrap(); + assert_eq!(created["name"], fill); + } + for params in [json!({"fill": fill}), json!({"attrs": {"fill": fill}})] { + session.execute("type.char", ¶ms).unwrap(); + assert_eq!(session.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], fill); + } + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Colored", "chars": {"fill": fill}})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": {"fill": fill}})).unwrap(); + let document = &session.doc().unwrap().doc; + for name in ["Colored", "Existing"] { + let attrs = + if kind == "paragraph" { &document.styles.para(name).unwrap().chars } else { &document.styles.char_style(name).unwrap().chars }; + assert_eq!(attrs.fill.as_deref(), Some(fill)); + } + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": {"fill": fill}})).unwrap()["count"], 1); + let selected = session.doc().unwrap().selection.text.unwrap(); + let story = session.doc().unwrap().doc.story(selected.story).unwrap(); + assert_eq!(story.text, "Bye"); + assert_eq!(story.format_after(0).over.fill.as_deref(), Some(fill)); + } +} + +#[test] +fn char_fill_accepts_omitted_and_null_attributes_when_used_at_all_boundaries() { + for chars in [json!({"size": 18}), json!({"fill": null})] { + let mut session = fill_session(); + session.execute("type.char", &json!({"attrs": chars})).unwrap(); + for kind in ["paragraph", "character"] { + session.execute(&format!("style.{kind}.create"), &json!({"name": "Nullable", "chars": chars})).unwrap(); + session.execute(&format!("style.{kind}.edit"), &json!({"name": "Existing", "chars": chars})).unwrap(); + } + assert_eq!(session.execute("find.change", &json!({"find": "Hi", "change": "Bye", "attrs": chars})).unwrap()["count"], 1); + } +} + +#[test] +fn percent_components_at_most_one_stay_percent() { + use designcraft_color::Color; + // {"c": 0.7, ..., "y": 12.2} is a pale cream (C 0.7 %, Y 12.2 %), not C 70 %. + let Some(Color::Cmyk { c, m, y, k }) = parse_color(&json!({"c": 0.7, "m": 0, "y": 12.2, "k": 0})) else { panic!("not CMYK") }; + assert!((c - 0.007).abs() < 1e-6 && m == 0.0 && (y - 0.122).abs() < 1e-6 && k == 0.0, "got {c} {m} {y} {k}"); + // Fractions take the colour's own form, with its model. + let Some(Color::Cmyk { c, k, .. }) = parse_color(&json!({"model": "cmyk", "c": 0.5, "m": 0, "y": 0, "k": 1})) else { panic!("not CMYK") }; + assert!((c - 0.5).abs() < 1e-6 && (k - 1.0).abs() < 1e-6); + // RGB arrays follow the same rule: [1, 128, 255] is 0..255. + let Some(Color::Rgb { r, g, b }) = parse_color(&json!([1, 128, 255])) else { panic!("not RGB") }; + assert!((r - 1.0 / 255.0).abs() < 1e-6 && (g - 128.0 / 255.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6); +} + +/// #910: `{c: 1, m: 0, y: 0, k: 0}` is a 1 % cyan tint, not solid cyan, though every component is within 0..1. +#[test] +fn cmyk_percentages_of_one_or_less_stay_percent() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("swatch.create", &json!({"name": "Tint1", "color": {"c": 1, "m": 0, "y": 0, "k": 0}})).unwrap(); + s.execute("object.fill", &json!({"ids": [id], "swatch": "Tint1"})).unwrap(); + let d = &s.doc().unwrap().doc; + let Some(Color::Cmyk { c, m, y, k }) = d.resolve_color("Tint1", 1.0) else { panic!("not CMYK") }; + assert!((c - 0.01).abs() < 1e-6 && m == 0.0 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); + let pdf = designcraft_pdf::export_pdf(d, &s.cache, &designcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let text = String::from_utf8_lossy(&pdf); + // Operands of every `k` (DeviceCMYK fill), last first: [k, y, m, c]. Inks are written in 1/255 steps (C 1 % is 3/255). + let fills: Vec> = + text.split(" k ").filter_map(|before| before.rsplit(' ').take(4).map(|x| x.parse().ok()).collect::>>()).collect(); + let cyan = |want: f32| fills.iter().any(|f| (f[3] - want).abs() < 0.5 / 255.0 + 1e-6 && f[..3] == [0.0; 3]); + assert!(cyan(0.01), "C 1 % in the PDF: {fills:?}"); + assert!(!cyan(1.0), "no solid cyan: {fills:?}"); + // A light tint whose components are all 1 or less, through object.color. + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.color", &json!({"color": {"c": 0.5, "m": 0.5, "y": 0, "k": 0}})).unwrap(); + let d = &s.doc().unwrap().doc; + let fill = &d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch; + let Some(Color::Cmyk { c, m, .. }) = d.resolve_color(fill, 1.0) else { panic!("not CMYK") }; + assert!((c - 0.005).abs() < 1e-6 && (m - 0.005).abs() < 1e-6, "got {c} {m}"); +} + +#[test] +fn a_colour_with_a_model_is_read_as_fractions_without_guessing() { + use designcraft_color::Color; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let fill = |s: &Session| { + let d = &s.doc().unwrap().doc; + d.resolve_color(&d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, 1.0).unwrap() + }; + // M 1 % from the Color panel: not M 100 % (magenta), though every component is within 0..1. + s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 0.01, "y": 0, "k": 0}})).unwrap(); + let Color::Cmyk { c, m, y, k } = fill(&s) else { panic!("not CMYK") }; + assert!(c == 0.0 && (m - 0.01).abs() < 1e-6 && y == 0.0 && k == 0.0, "got {c} {m} {y} {k}"); + // R 1 of 255 is a near black, not pure red. + s.execute("object.color", &json!({"color": {"model": "rgb", "r": 1.0 / 255.0, "g": 0, "b": 0}})).unwrap(); + let Color::Rgb { r, g, b } = fill(&s) else { panic!("not RGB") }; + assert!((r - 1.0 / 255.0).abs() < 1e-6 && g == 0.0 && b == 0.0, "got {r} {g} {b}"); + // Out of range or not a model: refused rather than guessed. + assert!(s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0, "m": 50, "y": 0, "k": 0}})).is_err()); + assert!(s.execute("object.color", &json!({"color": {"model": "hsb", "h": 0, "s": 0, "b": 0}})).is_err()); +} + +#[test] +fn colours_that_round_to_the_same_name_share_one_swatch() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let n = |s: &Session| s.doc().unwrap().doc.swatches.len(); + let first = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.5, "y": 1, "k": 0}})).unwrap(); + let before = n(&s); + for m in [0.501, 0.499, 0.5041] { + let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": m, "y": 1, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], first["swatch"], "M {m}"); + } + s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5, "g": 0.25, "b": 0}})).unwrap(); + s.execute("object.color", &json!({"color": {"model": "rgb", "r": 0.5005, "g": 0.25, "b": 0}})).unwrap(); + assert_eq!(n(&s), before + 1, "no hidden twins for values a fraction of a step apart"); + // A whole percent apart is a different colour. + let r = s.execute("object.color", &json!({"color": {"model": "cmyk", "c": 0.1, "m": 0.51, "y": 1, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], "C=10 M=51 Y=100 K=0"); +} + +#[test] +fn style_groups_rename_every_use() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + let r = s.execute("style.group", &json!({"kind": "paragraph", "names": ["Head", "Sub"], "group": "Titles"})).unwrap(); + assert_eq!(r["renamed"]["Head"], "Titles/Head"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0].style, "Titles/Head"); + assert_eq!(d.styles.para("Titles/Sub").unwrap().based_on.as_deref(), Some("Titles/Head")); + // Out of the group again. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Titles/Head"], "group": ""})).unwrap(); + assert!(s.doc().unwrap().doc.styles.para("Head").is_some()); + assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err()); +} + +#[test] +fn character_style_edit_sets_unsets_rebases_and_renames_every_use() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap(); + s.execute( + "style.paragraph.create", + &json!({"name": "Lead", "para": { + "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], + "nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}), + ) + .unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + + s.execute( + "style.character.edit", + &json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.styles.char_style("Emphasis").is_none()); + let st = d.styles.char_style("Strong").unwrap(); + assert_eq!(st.based_on.as_deref(), Some("Base")); + // Exactly the attributes set: `null` unset the font style, the rest stayed unset. + assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() }); + let story = d.story(designcraft_doc::StoryId(sid)).unwrap(); + assert!(story.chars.iter().any(|r| r.format.style == "Strong")); + assert!(story.chars.iter().all(|r| r.format.style != "Emphasis")); + let lead = d.styles.para("Lead").unwrap(); + assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(d.footnote_options.ref_char_style, "Strong"); + + // Based On [None] (null or the name) clears it. + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None); + + // Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None]. + s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap(); + assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err()); + assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err()); + assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty()); +} + +#[test] +fn continue_numbering_in_a_style_overrides_an_inherited_start_at() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Steps", "para": {"listType": "numbers", "startAt": 5}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "More Steps", "basedOn": "Steps"})).unwrap(); + let start = |s: &Session, name: &str| s.doc().unwrap().doc.styles.resolve_para_style(name).0.start_at; + assert_eq!(start(&s, "More Steps"), Some(5), "inherited"); + s.execute("style.paragraph.edit", &json!({"name": "More Steps", "para": {"startAt": null}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("More Steps").unwrap().para.start_at, Some(None)); + assert_eq!(start(&s, "More Steps"), None, "continues"); + assert_eq!(start(&s, "Steps"), Some(5), "the parent keeps its Start At"); + s.execute("style.paragraph.edit", &json!({"name": "Steps", "para": {"startAt": null}})).unwrap(); + assert_eq!(start(&s, "Steps"), None); + s.execute("style.paragraph.create", &json!({"name": "Fresh", "basedOn": "More Steps", "para": {"start_at": null}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Fresh").unwrap().para.start_at, Some(None)); +} + +#[test] +fn null_kinsoku_sets_the_default_rules_over_a_based_on_set() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let hard = serde_json::to_value(designcraft_doc::cjk::Kinsoku::named("HardKinsoku").unwrap()).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Base", "para": {"kinsoku": hard}})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "basedOn": "Base", "para": {"kinsoku": hard}})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"kinsoku": null}})).unwrap(); + let styles = s.doc().unwrap().doc.styles.clone(); + assert_eq!(styles.para("Body").unwrap().para.kinsoku, Some(None)); + assert_eq!(styles.resolve_para_style("Body").0.kinsoku, None, "Base's set no longer applies"); + // An edit without the attribute keeps it. + s.execute("style.paragraph.edit", &json!({"name": "Body", "para": {"rensuuji": false}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().para.kinsoku, Some(None)); +} + +#[test] +fn new_character_style_checks_its_based_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err()); + s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap(); + let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap(); + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); +} + +#[test] +fn swatches_save_and_load_ase() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Brand Teal", "color": "#108080"})).unwrap(); + let b64 = s.execute("swatch.save", &json!({"names": ["Brand Teal"]})).unwrap()["base64"].as_str().unwrap().to_string(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!(r["added"], 1); + assert!(s.doc().unwrap().doc.swatch("Brand Teal").is_some()); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert!(s.execute("swatch.load", &json!({"base64": "AAAA"})).is_err()); +} + +#[test] +fn loading_swatches_matches_the_first_swatch_of_each_name() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // Two swatches share a name, and an unnamed color is hidden. + s.edit(|d, _| { + d.swatches.extend([sw("Twin", 0.1), sw("Twin", 0.2), Swatch { named: false, hidden: true, ..sw("Unnamed", 0.3) }]); + Ok(()) + }) + .unwrap(); + let file = designcraft_color::ase::write(&[sw("New", 0.4), sw("New", 0.5), sw("Twin", 0.6), sw("Unnamed", 0.7), sw("[Black]", 0.8)]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.hidden)).collect() + }; + // With `replace`, the file's second "New" is added as "New 2", only the first "Twin" changes, the hidden + // swatch becomes visible, and the locked [Black] stays. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(2), Some(2))); + assert_eq!(named(&s, "New"), [(ink(0.4), false)]); + assert_eq!(named(&s, "New 2"), [(ink(0.5), false)]); + assert_eq!(named(&s, "Twin"), [(ink(0.6), false), (ink(0.2), false)]); + assert_eq!(named(&s, "Unnamed"), [(ink(0.7), false)]); + assert_eq!(named(&s, "[Black]"), [(ink(1.0), false)]); + // Without it, every name is already there and nothing changes. + let before = s.doc().unwrap().doc.swatches.clone(); + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(0))); + assert_eq!(s.doc().unwrap().doc.swatches, before); +} + +#[test] +fn loading_swatches_numbers_repeated_names_and_names_unnamed_colors() { + use designcraft_color::{Color, ColorType}; + let ink = |k: f32| SwatchValue::Color { color: Color::cmyk(0.0, 0.0, 0.0, k), color_type: ColorType::Process }; + let sw = |name: &str, k: f32| Swatch { name: name.into(), value: ink(k), locked: false, named: true, hidden: false }; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.edit(|d, _| { + d.swatches.push(sw("Kept", 0.1)); + Ok(()) + }) + .unwrap(); + // "Red 2" comes before the second "Red", which takes "Red 3". Two unnamed colors share a value. + let file = + designcraft_color::ase::write(&[sw("Red", 0.2), sw("Red 2", 0.3), sw("Red", 0.4), sw("", 0.55), sw("", 0.55), sw("", 0.65), sw("Kept", 0.7)]); + let b64 = crate::cmd::base64_encode(&file); + let named = |s: &Session, name: &str| -> Vec<(SwatchValue, bool)> { + s.doc().unwrap().doc.swatches.iter().filter(|w| w.name == name).map(|w| (w.value.clone(), w.named)).collect() + }; + // Names already in the document are kept. + let r = s.execute("swatch.load", &json!({"base64": b64})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(6), Some(0))); + assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); + assert_eq!(named(&s, "Red 2"), [(ink(0.3), true)]); + assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=55"), [(ink(0.55), false)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=55 2"), [(ink(0.55), false)]); + assert_eq!(named(&s, "C=0 M=0 Y=0 K=65"), [(ink(0.65), false)]); + assert_eq!(named(&s, "Kept"), [(ink(0.1), true)]); + assert!(named(&s, "").is_empty()); + // With `replace`, the same file again replaces each of its colors once. + let r = s.execute("swatch.load", &json!({"base64": b64, "replace": true})).unwrap(); + assert_eq!((r["added"].as_u64(), r["replaced"].as_u64()), (Some(0), Some(7))); + assert_eq!(named(&s, "Red"), [(ink(0.2), true)]); + assert_eq!(named(&s, "Red 3"), [(ink(0.4), true)]); + assert_eq!(named(&s, "Kept"), [(ink(0.7), true)]); +} + +#[test] +fn unnamed_colors_then_add_to_swatches() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 50, 50]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + let listed = |s: &Session| s.doc().unwrap().doc.swatches.iter().filter(|w| !w.hidden).count(); + let before = listed(&s); + let r = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); + assert_eq!(r["swatch"], "C=10 M=20 Y=30 K=0"); + assert_eq!(listed(&s), before, "unnamed: not in the Swatches panel"); + // The same colour again reuses it; the stroke target works too. + let n = s.doc().unwrap().doc.swatches.len(); + s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}, "target": "stroke"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.swatches.len(), n); + let d = &s.doc().unwrap().doc; + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.fill.swatch, it.stroke.swatch); + // Add to Swatches with a name: listed, and the object follows the rename. + s.execute("swatch.addToSwatches", &json!({"name": "Sand"})).unwrap(); + assert_eq!(listed(&s), before + 1); + let d = &s.doc().unwrap().doc; + assert_eq!(d.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); + // A colour equal to an existing swatch applies that swatch. + s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch, "Sand"); +} + +#[test] +fn add_to_swatches_renames_the_colour_in_text_cells_and_notes() { + use std::sync::Arc; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("endnote.insert", &json!({"text": "end"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 11})).unwrap(); + s.execute("footnote.insert", &json!({"text": "note"})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 4})).unwrap(); + let old = s.execute("object.color", &json!({"color": {"c": 10, "m": 20, "y": 30, "k": 0}})).unwrap()["swatch"].as_str().unwrap().to_string(); + // The same unnamed colour on a cell, the cell's text and the footnote's text. + s.edit(|d, _| { + let st = d.story_mut(sid).unwrap(); + let t = Arc::make_mut(st.tables.values_mut().next().unwrap()); + t.cells[0].fill = old.clone(); + t.cells[0].text.chars[0].format.over.fill = Some(old.clone()); + Arc::make_mut(&mut st.notes[0]).text.chars[0].format.over.stroke = Some(old.clone()); + Arc::make_mut(&mut st.endnotes[0]).text.chars[0].format.over.underline_color = Some(old.clone()); + Ok(()) + }) + .unwrap(); + s.execute("swatch.addToSwatches", &json!({"swatch": old, "name": "Sand"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + assert_eq!(st.char_format_at(2).over.fill.as_deref(), Some("Sand")); + let cell = &st.tables.values().next().unwrap().cells[0]; + assert_eq!(cell.fill, "Sand"); + assert_eq!(cell.text.chars[0].format.over.fill.as_deref(), Some("Sand")); + assert_eq!(st.notes[0].text.chars[0].format.over.stroke.as_deref(), Some("Sand")); + assert_eq!(st.endnotes[0].text.chars[0].format.over.underline_color.as_deref(), Some("Sand")); + d.check().unwrap(); +} diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs new file mode 100644 index 000000000..0eebe1748 --- /dev/null +++ b/crates/engine/src/cmd/style/object.rs @@ -0,0 +1,164 @@ +use super::{Effects, Item, ObjectStyle, Result, Session, Styles, Value, bad, json, str_param}; + +pub(super) fn apply_object(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.object.apply", "missing name"))?.to_string(); + let os = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad("style.object.apply", format!("no object style `{name}`")))?; + let ids = crate::cmd::targets(s, p)?; + s.edit(|d, _| { + let mut stories = Vec::new(); + for id in &ids { + let Some(it) = d.item_mut(*id) else { continue }; + it.object_style = name.clone(); + os.apply_to(it); + if let Some(tf) = it.text_frame() { + stories.push(tf.story); + } + } + if let Some(ps) = &os.paragraph_style { + for sid in stories { + if let Some(st) = d.story_mut(sid) { + let len = st.len(); + st.format_paras(0..len, |f| f.style = ps.clone()); + } + } + } + Ok(Value::Null) + }) +} + +pub(super) fn clear_object_overrides(s: &mut Session, p: &Value) -> Result { + let ids = crate::cmd::targets(s, p)?; + // Each target with its style's attributes applied, kept only where that changes the item. + let d = &s.doc()?.doc; + let changed: Vec = ids + .iter() + .filter_map(|id| { + let it = d.item(*id)?; + let mut next = it.clone(); + d.styles.object_style(&it.object_style)?.apply_to(&mut next); + (next != *it).then_some(next) + }) + .collect(); + if changed.is_empty() { + return Ok(json!({"changed": 0})); + } + s.edit(|d, _| { + for next in &changed { + if let Some(it) = d.item_mut(next.id) { + *it = next.clone(); + } + } + Ok(json!({"changed": changed.len()})) + }) +} + +/// Effect keys accepted at the top level of `style.object.create` / `edit` as shorthand for +/// `effects.`. +const EFFECT_KEYS: [&str; 9] = + ["dropShadow", "feather", "innerShadow", "outerGlow", "innerGlow", "bevel", "satin", "gradientFeather", "directionalFeather"]; + +/// The effects `p` gives (`effects`, then the top-level effect keys) merged field by field over +/// `base`, or `None` when `p` names no effect. +fn effects_param(cmd: &str, p: &Value, base: &Effects) -> Result> { + let mut patch = serde_json::Map::new(); + match p.get("effects") { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => patch.extend(o.iter().map(|(k, v)| (k.clone(), v.clone()))), + Some(_) => return Err(bad(cmd, "`effects` must be an object or null")), + } + for k in EFFECT_KEYS { + if let Some(v) = p.get(k) { + patch.insert(k.to_string(), v.clone()); + } + } + if patch.is_empty() { + return Ok(None); + } + let mut v = serde_json::to_value(base).map_err(|e| bad(cmd, e.to_string()))?; + merge_json(&mut v, Value::Object(patch), 0); + let e: Effects = serde_json::from_value(v).map_err(|e| bad(cmd, format!("effects: {e}")))?; + e.sanitized().map(Some).map_err(|m| bad(cmd, m)) +} + +/// Merges `top` into `base`: objects key by key, anything else replaced. +fn merge_json(base: &mut Value, top: Value, depth: usize) { + match (base, top) { + (Value::Object(b), Value::Object(t)) if depth < 8 => { + for (k, v) in t { + match b.get_mut(&k) { + Some(slot) => merge_json(slot, v, depth + 1), + None => { + b.insert(k, v); + } + } + } + } + (b, t) => *b = t, + } +} + +pub(super) fn create_object(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Object Style 1").to_string(); + let from_sel = p.get("fromSelection").and_then(Value::as_bool).unwrap_or(true); + let src = if from_sel { s.doc()?.selection.items.first().and_then(|i| s.doc().ok()?.doc.item(*i).cloned()) } else { None }; + let mut os = ObjectStyle::default(); + if let Some(it) = &src { + os.fill = Some(it.fill.clone()); + os.stroke = Some(it.stroke.clone()); + os.text_frame = it.text_frame().map(|t| t.options.clone()); + os.effects = Some(it.effects.clone()); + } + if let Some(sw) = str_param(p, "fill") { + os.fill = Some(designcraft_doc::Fill::swatch(sw)); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + os.paragraph_style = Some(ps.to_string()); + } + if let Some(e) = effects_param("style.object.create", p, os.effects.as_ref().unwrap_or(&Effects::default()))? { + os.effects = Some(e); + } + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.styles.object_style(n).is_some(), &base); + os.name = name.clone(); + d.styles_mut().object.push(os.clone()); + Ok(json!({"name": name})) + }) +} + +pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "style.object.edit"; + let name = str_param(p, "name").ok_or_else(|| bad(CMD, "missing name"))?.to_string(); + if name == designcraft_doc::NO_OBJECT_STYLE { + return Err(bad(CMD, format!("`{name}` can't be edited"))); + } + let old = s.doc()?.doc.styles.object_style(&name).cloned().ok_or_else(|| bad(CMD, format!("no object style `{name}`")))?; + let effects = if p.get("effects").is_some_and(Value::is_null) { + if EFFECT_KEYS.iter().any(|k| p.get(k).is_some()) { + return Err(bad(CMD, "`effects: null` can't be combined with effect settings")); + } + None + } else { + match effects_param(CMD, p, old.effects.as_ref().unwrap_or(&Effects::default()))? { + Some(e) => Some(e), + None => return Ok(json!({"name": name, "updated": 0})), + } + }; + if effects == old.effects { + return Ok(json!({"name": name, "updated": 0})); + } + s.edit(|d, _| { + let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { + return Err(bad(CMD, format!("no object style `{name}`"))); + }; + os.effects = effects.clone(); + // An item follows the style while its effects are the style's. When the style didn't set + // effects before, only items without effects of their own follow (the default styles are + // on most frames). + let follows = old.effects.clone().unwrap_or_default(); + let updated = match &effects { + Some(new) => d.update_items(|it| it.object_style == name && it.effects == follows, |it| it.effects = new.clone()), + None => 0, + }; + Ok(json!({"name": name, "updated": updated})) + }) +} diff --git a/crates/engine/src/cmd/style/paragraph.rs b/crates/engine/src/cmd/style/paragraph.rs new file mode 100644 index 000000000..81711ff10 --- /dev/null +++ b/crates/engine/src/cmd/style/paragraph.rs @@ -0,0 +1,1236 @@ +use super::{CharAttrs, CharacterStyle, ParaAttrs, ParagraphStyle, Result, Session, Styles, Value, bad, json, ok, str_param}; + +#[cfg(test)] +mod redefine_tests { + use super::*; + + fn sample() -> (Session, Value, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text","text":"First header"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect":[36,240,300,400],"content":"text","text":"Second header"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name":"Header","chars":{"size":18,"fill":"[Paper]"}})).unwrap(); + for story in [&a["story"], &b["story"]] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name":"Header"})).unwrap(); + } + s.execute("text.select", &json!({"story":a["story"],"anchor":0,"focus":5})).unwrap(); + (s, a["story"].clone(), b["story"].clone()) + } + + #[test] + fn redefine_paragraph_updates_other_stories_and_clears_the_source_overrides() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":20,"fill":"[Black]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter":9})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.redefine", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(s.doc().unwrap().doc.styles.para("Header").unwrap().chars.underline.is_none()); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(attrs["chars"]["size"], 20.0); + assert_eq!(attrs["chars"]["fill"], "[Black]"); + assert_eq!(attrs["para"]["spaceAfter"], 9.0); + assert_eq!(attrs["chars"]["underline"], story == &a); + } + s.execute("style.paragraph.edit", &json!({"name":"Header","chars":{"size":22}})).unwrap(); + s.execute("text.select", &json!({"story":a,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 22.0); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + + #[test] + fn redefine_character_does_not_capture_paragraph_font_size() { + let (mut s, a, b) = sample(); + s.execute("style.character.create", &json!({"name":"Underline","chars":{"underline":true}})).unwrap(); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Underline"})).unwrap(); + } + s.execute("type.char", &json!({"fill":"[Black]"})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Underline"})).unwrap(); + let style = s.doc().unwrap().doc.styles.char_style("Underline").unwrap(); + assert!(style.chars.size.is_none()); + assert_eq!(style.chars.underline, Some(true)); + for story in [&a, &b] { + s.execute("text.select", &json!({"story":story,"anchor":0,"focus":5})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], "[Black]"); + } + } + + #[test] + fn redefine_rejects_the_wrong_style_and_the_none_entry_atomically() { + let (mut s, _, _) = sample(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("style.paragraph.redefine", &json!({"name":"Other"})).is_err()); + assert!(s.execute("style.character.redefine", &json!({})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn redefine_character_at_a_caret_cleans_the_source_run_but_keeps_other_overrides() { + let (mut s, story, _) = sample(); + s.execute("style.character.create", &json!({"name":"Emphasis","chars":{"underline":true}})).unwrap(); + for (start, end, size) in [(0, 5, 24), (6, 12, 32)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + s.execute("style.character.apply", &json!({"name":"Emphasis"})).unwrap(); + s.execute("type.char", &json!({"size":size})).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + s.execute("text.select", &json!({"story":story,"anchor":2,"focus":2})).unwrap(); + s.execute("style.character.redefine", &json!({"name":"Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name":"Emphasis","chars":{"size":30}})).unwrap(); + for (start, end, size) in [(0, 5, 30.0), (6, 12, 32.0)] { + s.execute("text.select", &json!({"story":story,"anchor":start,"focus":end})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], size); + } + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } +} + +#[cfg(test)] +mod application_inheritance_tests { + use super::*; + + fn setup(text: &str) -> (Session, Value) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [36,36,300,300], "content": "text", "text": text})).unwrap(); + (s, f["story"].clone()) + } + fn select(s: &mut Session, story: &Value, start: usize, end: usize) { + s.execute("text.select", &json!({"story": story, "anchor": start, "focus": end})).unwrap(); + } + fn attrs(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap() + } + + #[test] + fn paragraph_capture_source_follows_later_style_edits_and_undo() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 18, "fill": "[Paper]"})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 6})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Header", "fromSelection": true})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + s.execute("edit.redo", &json!({})).unwrap(); + select(&mut s, &story, 6, 6); + s.execute("style.paragraph.apply", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Header", "chars": {"size": 20, "fill": "[Black]"}, "para": {"spaceAfter": 12}})).unwrap(); + for pos in [0, 6] { + select(&mut s, &story, pos, pos + 1); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 20.0); + assert_eq!(a["chars"]["fill"], "[Black]"); + assert_eq!(a["para"]["spaceAfter"], 12.0); + } + } + + #[test] + fn applying_styles_keeps_different_overrides_and_named_character_styles() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"size": 14})).unwrap(); + s.execute("type.para", &json!({"spaceAfter": 9})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"underline": true}})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body", "chars": {"size": 10}, "para": {"spaceAfter": 6}})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Body"})).unwrap(); + let a = attrs(&mut s); + assert_eq!(a["chars"]["size"], 14.0); + assert_eq!(a["para"]["spaceAfter"], 9.0); + assert_eq!(a["characterStyle"], "Emphasis"); + assert_eq!(a["chars"]["underline"], true); + } + + #[test] + fn character_capture_source_remains_sparse_and_follows_style_edits() { + let (mut s, story) = setup("First\nSecond"); + select(&mut s, &story, 0, 5); + s.execute("type.char", &json!({"underline": true})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "fromSelection": true})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Emphasis", "chars": {"underline": false}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["underline"], false); + assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").unwrap().chars.size.is_none()); + s.execute("style.paragraph.edit", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "chars": {"size": 20}})).unwrap(); + assert_eq!(attrs(&mut s)["chars"]["size"], 20.0); + } + + #[test] + fn paragraph_selection_excludes_the_next_paragraph_at_its_start() { + let (mut s, story) = setup("é\nNext"); + select(&mut s, &story, 0, 3); + s.execute("style.paragraph.create", &json!({"name": "Header"})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Header", "clearOverrides": true})).unwrap(); + select(&mut s, &story, 3, 4); + assert_eq!(attrs(&mut s)["paragraphStyle"], designcraft_doc::BASIC_PARAGRAPH); + select(&mut s, &story, 0, 2); + assert_eq!(attrs(&mut s)["paragraphStyle"], "Header"); + } + + #[test] + fn clearing_a_partial_selection_preserves_character_overrides_outside_it() { + let (mut s, story) = setup("First Second"); + select(&mut s, &story, 0, 12); + s.execute("type.char", &json!({"size": 18})).unwrap(); + select(&mut s, &story, 0, 5); + s.execute("style.paragraph.apply", &json!({"name": designcraft_doc::BASIC_PARAGRAPH, "clearOverrides": true})).unwrap(); + assert_ne!(attrs(&mut s)["chars"]["size"], 18.0); + select(&mut s, &story, 6, 12); + assert_eq!(attrs(&mut s)["chars"]["size"], 18.0); + } +} + +/// The `group` (`chars`, `para`) object of command `cmd`'s params as attributes; a bad attribute +/// is that command's parameter error, naming the group. +fn attrs(cmd: &str, group: &str, p: &Value, set: impl Fn(&mut T, &str, &Value) -> std::result::Result<(), String>) -> Result { + let mut a = T::default(); + match p.get(group) { + None | Some(Value::Null) => {} + Some(Value::Object(o)) => { + for (k, v) in o { + set(&mut a, k, v).map_err(|e| bad(cmd, format!("{group}: {e}")))?; + } + } + Some(_) => return Err(bad(cmd, format!("{group} must be an object of attributes"))), + } + Ok(a) +} + +pub(super) fn redefine(s: &mut Session, p: &Value, paragraph: bool) -> Result { + let command = if paragraph { "style.paragraph.redefine" } else { "style.character.redefine" }; + let targets = crate::cmd::text::format_targets_pub(s); + let target = targets.first().ok_or_else(|| bad(command, "select text using the style to redefine"))?; + let state = s.doc()?; + let story = state.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(command, "no paragraph at the selection"))?; + let cf = crate::cmd::text::effective_char_format(s, target)?; + let selected = if paragraph { &pf.style } else { &cf.style }; + let name = str_param(p, "name").unwrap_or(selected).to_string(); + if name != *selected || name == if paragraph { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { + return Err(bad(command, "select text using the named style; the no-style entry cannot be redefined")); + } + let (base_para, base_chars) = state.doc.styles.resolve_para_style(&pf.style); + let (resolved_para, paragraph_chars) = state.doc.styles.resolve_para(pf); + let para_changes = base_para.delta(&resolved_para); + let mut current = if paragraph { + paragraph_chars.clone() + } else { + state.doc.styles.resolve_char(¶graph_chars, &designcraft_doc::CharFormat { style: name.clone(), over: CharAttrs::default() }) + }; + let inherited = if paragraph { base_chars } else { current.clone() }; + // A named character style is an intentional layer, not a paragraph override to absorb. + current.apply(&cf.over); + let char_changes = inherited.delta(¤t); + let result = s.edit(|d, _| { + if paragraph { + let style = d.styles_mut().para_mut(&name).ok_or_else(|| bad(command, "paragraph style no longer exists"))?; + style.para.merge(¶_changes); + style.chars.merge(&char_changes); + } else { + let style = + d.styles_mut().character.iter_mut().find(|st| st.name == name).ok_or_else(|| bad(command, "character style no longer exists"))?; + style.chars.merge(&char_changes); + } + let styles = d.styles.clone(); + for target in &targets { + let Some(story) = d.text_story_mut(target.story, target.cell) else { continue }; + // At a character-style caret, the sampled format belongs to the containing run + // (the preceding run at a boundary). Normalize that source run too, otherwise its + // old local values would mask the next edit of the redefined style. + let mut selected_range = target.range.clone(); + if !paragraph && selected_range.is_empty() { + let caret = selected_range.start; + let mut start = 0usize; + for run in &story.chars { + let end = start.saturating_add(run.len).min(story.len()); + if caret < end || (caret == end && caret > start) { + selected_range = start..end; + break; + } + start = end; + } + } + let end = if selected_range.is_empty() { selected_range.start } else { selected_range.end.saturating_sub(1) }; + let (first, last) = (story.para_at(selected_range.start), story.para_at(end)); + for (index, range) in story.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = story.paras.get_mut(index) else { continue }; + if paragraph && pf.style != name { + continue; + } + if paragraph { + let (pp, cp) = styles.resolve_para(pf); + let (base_p, base_c) = styles.resolve_para_style(&name); + pf.para = base_p.delta(&pp); + pf.chars = base_c.delta(&cp); + } + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(story.len()); + let range = if paragraph { range.start..end } else { range.start.max(selected_range.start)..end.min(selected_range.end) }; + story.format_chars(range, |format| { + if !paragraph && format.style != name { + return; + } + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&format.style)); + let resolved = styles.resolve_char(&base, format); + format.over = inherited.delta(&resolved); + }); + } + } + Ok(json!({"name":name})) + }); + if result.is_ok() { + s.typing_format = None; + } + result +} + +/// Apply paragraph style `name` over `r` in one story; `clear` drops overrides instead of keeping +/// only those that differ from the style (`resolved` is the style's paragraph and character attributes). +fn apply_para_in_story( + st: &mut designcraft_doc::Story, + r: &std::ops::Range, + name: &str, + clear: bool, + resolved: (&designcraft_doc::ParaProps, &designcraft_doc::CharProps), + styles: &designcraft_doc::Styles, +) { + let (style_para, style_chars) = resolved; + // Text selections are half-open: a selection ending at the next paragraph's + // start must not apply a style to that paragraph too. + let end = if r.is_empty() { r.start } else { r.end.saturating_sub(1) }; + let (first, last) = (st.para_at(r.start), st.para_at(end)); + st.format_paras(r.start..end, |f| { + f.style = name.to_string(); + if clear { + f.para = ParaAttrs::default(); + f.chars = CharAttrs::default(); + } else { + f.para.retain_differences(style_para); + f.chars.retain_differences(style_chars); + } + }); + for (pi, range) in st.para_ranges().into_iter().enumerate().take(last.saturating_add(1)).skip(first) { + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, base) = styles.resolve_para(pf); + let end = range.end.saturating_add(1).min(st.len()); + let range = if clear && !r.is_empty() { range.start.max(r.start)..end.min(r.end) } else { range.start..end }; + st.format_chars(range, |f| { + if clear { + f.over = CharAttrs::default(); + } else { + let mut inherited = base.clone(); + inherited.apply(&styles.char_style_attrs(&f.style)); + f.over.retain_differences(&inherited); + } + }); + } +} + +pub(super) fn apply_para(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.apply", "missing name"))?.to_string(); + let clear = p.get("clearOverrides").and_then(Value::as_bool).unwrap_or(false); + if s.doc()?.doc.styles.para(&name).is_none() { + return Err(bad("style.paragraph.apply", format!("no paragraph style `{name}`"))); + } + let targets = crate::cmd::text::format_targets_pub(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + let (style_para, style_chars) = styles.resolve_para_style(&name); + for t in &targets { + if let Some(st) = d.text_story_mut(t.story, t.cell) { + apply_para_in_story(st, &t.range, &name, clear, (&style_para, &style_chars), &styles); + } + } + ok() + }) +} + +pub(super) fn apply_char(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.character.apply", "missing name"))?.to_string(); + if s.doc()?.doc.styles.char_style(&name).is_none() { + return Err(bad("style.character.apply", format!("no character style `{name}`"))); + } + let targets = crate::cmd::text::format_targets_pub(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + if let Some(st) = d.text_story_mut(t.story, t.cell) { + for (pi, range) in st.para_ranges().into_iter().enumerate() { + let range = range.start.max(t.range.start)..range.end.saturating_add(1).min(st.len()).min(t.range.end); + if range.is_empty() { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, mut inherited) = styles.resolve_para(pf); + inherited.apply(&styles.char_style_attrs(&name)); + st.format_chars(range, |f| { + f.style = name.clone(); + f.over.retain_differences(&inherited); + }); + } + } + } + ok() + }) +} + +/// In a style, `startAt: null` is Continue from Previous Number: it is stored as an explicit +/// "continue" so it also overrides a Start At the style would inherit. +fn continue_numbering(p: Option<&Value>, para: &mut ParaAttrs) { + let explicit = + p.and_then(Value::as_object).is_some_and(|o| o.iter().any(|(k, v)| v.is_null() && k.replace('_', "").eq_ignore_ascii_case("startAt"))); + if explicit { + para.start_at = Some(None); + } +} + +pub(super) fn create_para(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Paragraph Style 1").to_string(); + let mut para: ParaAttrs = + attrs("style.paragraph.create", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, &designcraft_doc::ParaProps::default()))?; + continue_numbering(p.get("para"), &mut para); + explicit_default_kinsoku(&mut para, p.get("para")); + check_grep_styles("style.paragraph.create", ¶)?; + let mut chars: CharAttrs = attrs("style.paragraph.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills, not selection fills retained after a swatch is deleted. + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Ok(cur) = s.execute("type.selectionAttrs", &json!({})) + && !cur.is_null() + { + if let Ok(pp) = serde_json::from_value::(cur["para"].clone()) { + let mut full = ParaProps_to_attrs(&pp); + full.merge(¶); + para = full; + } + if let Ok(cp) = serde_json::from_value::(cur["chars"].clone()) { + let mut full = CharProps_to_attrs(&cp); + full.merge(&chars); + chars = full; + } + } + let based_on = str_param(p, "basedOn").map(str::to_string); + let next = str_param(p, "nextStyle").map(str::to_string); + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.styles.para(n).is_some(), &base); + if let Some(b) = &based_on + && d.styles.para(b).is_none() + { + return Err(bad("style.paragraph.create", format!("no style `{b}`"))); + } + d.styles_mut().paragraph.push(ParagraphStyle { name: name.clone(), based_on, next_style: next, para, chars, shortcut: String::new() }); + Ok(json!({"name": name})) + }) +} + +/// `kinsoku: null` sets the composer's default kinsoku rules, also over a Based On style's set. +/// For the other attributes `null` leaves the style's value as it is. +fn explicit_default_kinsoku(para: &mut ParaAttrs, given: Option<&Value>) { + if given.and_then(|o| o.get("kinsoku")).is_some_and(Value::is_null) { + para.kinsoku = Some(None); + } +} + +/// The compile error of the first GREP style in `a` whose pattern can't compile. +pub(crate) fn grep_style_error(a: &ParaAttrs) -> Option { + a.grep_styles + .iter() + .flatten() + .filter(|g| !g.pattern.is_empty()) + .find_map(|g| designcraft_compose::grep::Grep::new(&g.pattern, Default::default()).err().map(|e| format!("{e} (pattern {:?})", g.pattern))) +} + +/// Rejects GREP styles a command would set with a pattern that can't compile. +pub(crate) fn check_grep_styles(cmd: &str, a: &ParaAttrs) -> Result<()> { + grep_style_error(a).map_or(Ok(()), |e| Err(bad(cmd, e))) +} + +/// One warning for each paragraph style or paragraph whose GREP style pattern can't compile. +/// Such a GREP style stays in the document and composition skips it. +pub(crate) fn grep_style_warnings(d: &designcraft_doc::Document) -> Vec { + let mut out: Vec = d + .styles + .paragraph + .iter() + .filter_map(|st| grep_style_error(&st.para).map(|e| format!("paragraph style {:?}: GREP style not applied: {e}", st.name))) + .collect(); + for st in d.stories.values() { + for (i, pf) in st.paras.iter().enumerate() { + if let Some(e) = grep_style_error(&pf.para) { + out.push(format!("story {} paragraph {}: GREP style not applied: {e}", st.id.0, i + 1)); + } + } + } + out +} + +#[allow(non_snake_case)] +fn ParaProps_to_attrs(p: &designcraft_doc::ParaProps) -> ParaAttrs { + designcraft_doc::ParaProps::common([p]) +} + +#[allow(non_snake_case)] +fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { + designcraft_doc::CharProps::common([p]) +} + +pub(super) fn edit_para(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); + let chars: CharAttrs = attrs("style.paragraph.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.paragraph.edit")?; + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); + let based = p.get("basedOn").cloned(); + s.edit(|d, _| { + if let Some(Value::String(b)) = &based + && d.styles.para_based_on_cycles(&name, b) + { + return Err(bad("style.paragraph.edit", "based-on would create a cycle")); + } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.paragraph.edit", format!("can't rename to `{n}`"))); + } + if d.styles.para(n).is_some() { + return Err(bad("style.paragraph.edit", format!("a paragraph style named `{n}` already exists"))); + } + } + // A rule object changes only the rule fields it names, over the style's resolved rule. + let (current, _) = d.styles.resolve_para_style(&name); + let mut para: ParaAttrs = attrs("style.paragraph.edit", "para", p, |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; + continue_numbering(p.get("para"), &mut para); + explicit_default_kinsoku(&mut para, p.get("para")); + check_grep_styles("style.paragraph.edit", ¶)?; + let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; + st.para.merge(¶); + st.chars.merge(&chars); + match based { + Some(Value::String(b)) => st.based_on = Some(b), + Some(Value::Null) => st.based_on = None, + _ => {} + } + if let Some(n) = rename.clone() { + // Every use: other styles, stories, table cells, footnotes, object styles. + rename_style(d, true, &name, &n); + } + ok() + }) +} + +/// Rename a paragraph or character style everywhere it's used; its Export Tagging follows. +fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let st = d.styles_mut(); + if para { + for s in &mut st.paragraph { + if s.name == from { + s.name = to.to_string(); + } + } + } else { + for s in &mut st.character { + if s.name == from { + s.name = to.to_string(); + } + } + } + // An entry already under `to` belongs to no style (names are unique), so it never survives. + st.export_tags.remove(&export_tag_key(para, to)); + if let Some(tag) = st.export_tags.remove(&export_tag_key(para, from)) { + st.export_tags.insert(export_tag_key(para, to), tag); + } + redirect_style_refs(d, para, from, to); +} + +/// The `Styles::export_tags` key of a paragraph (`para`) or character style. +pub(super) fn export_tag_key(para: bool, name: &str) -> String { + format!("{}:{name}", if para { "p" } else { "c" }) +} + +/// Point every reference to style `from` at `to`, except the style's own registry entry (its +/// `name` field) — shared by renaming (which also updates the entry's name) and deletion (which +/// instead removes the entry, after its former uses point at the replacement). +fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { + let rename = |r: &mut String| { + if r == from { + *r = to.to_string(); + } + }; + let st = d.styles_mut(); + // A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent. + let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned(); + if para { + let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); + for s in &mut st.paragraph { + if s.based_on.as_deref() == Some(from) { + s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; + } + if s.next_style.as_deref() == Some(from) { + s.next_style = Some(to.to_string()); + } + } + let objects = st.object.iter_mut().map(|o| &mut o.paragraph_style); + for r in objects.chain(st.cell.iter_mut().map(|c| &mut c.paragraph_style)) { + if r.as_deref() == Some(from) { + *r = Some(to.to_string()); + } + } + rename(&mut st.default_paragraph); + let (fo, eo) = (&mut d.footnote_options, &mut d.endnote_options); + [&mut fo.para_style, &mut eo.para_style, &mut eo.heading_style].into_iter().for_each(rename); + d.toc.iter_mut().flat_map(|t| t.entries.iter_mut().map(|e| &mut e.style)).for_each(rename); + for t in d.toc.iter_mut() { + let formats = t.entries.iter_mut().map(|e| &mut e.entry_style).chain([&mut t.title_style]); + for r in formats.filter(|r| r.as_deref() == Some(from)) { + *r = Some(to.to_string()); + } + } + if let designcraft_doc::EpubSplit::Style(n) = &mut d.settings.epub_split { + rename(n); + } + } else { + let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref())); + for s in &mut st.character { + if s.based_on.as_deref() == Some(from) { + s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) }; + } + } + for s in &mut st.paragraph { + rename_char_refs(&mut s.para, from, to); + } + rename(&mut st.default_character); + rename(&mut d.footnote_options.ref_char_style); + } + // The table of contents' character styles ([None] is stored as no style). + if !para && let Some(toc) = d.toc.as_mut() { + for e in &mut toc.entries { + for n in [&mut e.page_number_style, &mut e.separator_style] { + if n.as_deref() == Some(from) { + *n = Some(to.to_string()).filter(|t| t != designcraft_doc::NO_CHAR_STYLE); + } + } + } + } + for sid in d.stories.keys().copied().collect::>() { + let Some(story) = d.story_mut(sid) else { continue }; + story.for_each_text_mut(&mut |st| { + if para { + for f in st.paras.iter_mut().filter(|f| f.style == from) { + f.style = to.to_string(); + } + } else { + for r in st.chars.iter_mut().filter(|r| r.format.style == from) { + r.format.style = to.to_string(); + } + for f in &mut st.paras { + rename_char_refs(&mut f.para, from, to); + } + } + st.rev = st.rev.wrapping_add(1); + }); + } +} + +/// Point the nested, nested line and GREP styles, and the drop cap style, of paragraph +/// attributes at a renamed (or deleted, pointed at its replacement) character style. +fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) { + let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style); + let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style)); + let names = names.chain(p.drop_cap_style.iter_mut()); + for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) { + if n == from { + *n = to.to_string(); + } + } +} + +pub(super) fn group_styles(s: &mut Session, p: &Value) -> Result { + let para = match str_param(p, "kind").unwrap_or("paragraph") { + "paragraph" => true, + "character" => false, + k => return Err(bad("style.group", format!("unknown kind `{k}`"))), + }; + let names: Vec = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let group = str_param(p, "group").unwrap_or("").trim_matches('/').to_string(); + s.edit(|d, _| { + let mut renamed = serde_json::Map::new(); + for n in &names { + if n.starts_with('[') { + return Err(bad("style.group", format!("`{n}` is built in"))); + } + let exists = if para { d.styles.para(n).is_some() } else { d.styles.character.iter().any(|c| c.name == *n) }; + if !exists { + return Err(bad("style.group", format!("no style `{n}`"))); + } + let base = n.rsplit('/').next().unwrap_or(n); + let mut to = if group.is_empty() { base.to_string() } else { format!("{group}/{base}") }; + let taken = |d: &designcraft_doc::Document, x: &str| { + if para { d.styles.para(x).is_some() } else { d.styles.character.iter().any(|c| c.name == x) } + }; + if to != *n { + to = Styles::unique_name(|x| taken(d, x), &to); + rename_style(d, para, n, &to); + renamed.insert(n.clone(), json!(to)); + } + } + Ok(json!({"renamed": renamed})) + }) +} + +pub(super) fn delete_para(s: &mut Session, p: &Value) -> Result { + delete_style(s, p, true) +} + +pub(super) fn delete_char(s: &mut Session, p: &Value) -> Result { + delete_style(s, p, false) +} + +/// Delete a paragraph (`para`) or character style; everything that used it uses `replaceWith` +/// (default [Basic Paragraph] or [None]) instead, in one undo step. +fn delete_style(s: &mut Session, p: &Value, para: bool) -> Result { + let (id, default) = + if para { ("style.paragraph.delete", designcraft_doc::BASIC_PARAGRAPH) } else { ("style.character.delete", designcraft_doc::NO_CHAR_STYLE) }; + let name = str_param(p, "name").filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "missing name"))?.to_string(); + let repl = str_param(p, "replaceWith").unwrap_or(default).to_string(); + if name.starts_with('[') { + return Err(bad(id, "built-in styles can't be deleted")); + } + if repl == name { + return Err(bad(id, "`replaceWith` can't be the deleted style")); + } + s.edit(|d, _| { + let st = &d.styles; + let exists = |n: &str| if para { st.para(n).is_some() } else { n == designcraft_doc::NO_CHAR_STYLE || st.char_style(n).is_some() }; + if !exists(&name) { + return Err(bad(id, format!("no style `{name}`"))); + } + if !exists(&repl) { + return Err(bad(id, format!("no style `{repl}` to replace it with"))); + } + // The deleted style's children are based on the replacement, or on the deleted style's + // own parent when the replacement descends from it (so no based-on chain loops). + let (own_parent, descends) = if para { + (st.para(&name).and_then(|x| x.based_on.clone()), st.para_based_on_cycles(&name, &repl)) + } else { + (st.char_style(&name).and_then(|x| x.based_on.clone()), st.char_based_on_cycles(&name, &repl)) + }; + let parent = if descends { + own_parent.filter(|b| *b != name) + } else if repl == designcraft_doc::NO_CHAR_STYLE || repl == designcraft_doc::NO_PARA_STYLE { + None + } else { + Some(repl.clone()) + }; + let st = d.styles_mut(); + let based = if para { + st.paragraph.retain(|x| x.name != name); + st.paragraph.iter_mut().map(|x| &mut x.based_on).collect::>() + } else { + st.character.retain(|x| x.name != name); + st.character.iter_mut().map(|x| &mut x.based_on).collect() + }; + for b in based.into_iter().filter(|b| b.as_deref() == Some(name.as_str())) { + *b = parent.clone(); + } + // The replacement keeps its own Export Tagging. + st.export_tags.remove(&export_tag_key(para, &name)); + // Every other use: styles, stories, table cells, nested and GREP styles, notes, defaults. + redirect_style_refs(d, para, &name, &repl); + ok() + }) +} + +pub(super) fn create_char(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Character Style 1").to_string(); + let mut chars: CharAttrs = attrs("style.character.create", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + // Validate caller-supplied fills; captured formatting may retain a deleted swatch name. + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.create")?; + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) + && let Some(target) = crate::cmd::text::format_targets_pub(s).first() + { + let st = s.doc()?; + // Match paragraph fromSelection's first-character/caret policy, but keep paragraph + // attributes inherited instead of freezing the fully resolved character properties. + let format = crate::cmd::text::effective_char_format(s, target)?; + let mut captured = st.doc.styles.char_style_attrs(&format.style); + captured.merge(&format.over); + // Replay the explicit fields so null clears a captured value rather than looking absent. + if let Some(overrides) = p.get("chars").and_then(Value::as_object) { + for (key, value) in overrides { + captured.set_json(key, value).map_err(|e| bad("style.character.create", format!("chars: {e}")))?; + } + } + chars = captured; + } + // Based on [None] is based on nothing. + let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string); + s.edit(|d, _| { + if let Some(b) = &based_on + && d.styles.char_style(b).is_none() + { + return Err(bad("style.character.create", format!("no character style `{b}`"))); + } + let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base); + d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() }); + Ok(json!({"name": name})) + }) +} + +pub(super) fn edit_char(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string(); + if name == designcraft_doc::NO_CHAR_STYLE { + return Err(bad("style.character.edit", "[None] can't be edited")); + } + let chars: CharAttrs = attrs("style.character.edit", "chars", p, |a: &mut CharAttrs, k, v| a.set_json(k, v))?; + crate::cmd::text::validate_char_fill(&s.doc()?.doc, &chars, "style.character.edit")?; + // `null` unsets an attribute (the style no longer sets it). + let unset: Vec = p + .get("chars") + .and_then(Value::as_object) + .map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()) + .unwrap_or_default(); + let rename = str_param(p, "rename").map(str::trim).filter(|n| *n != name).map(str::to_string); + let based: Option> = match p.get("basedOn") { + None => None, + Some(Value::Null) => Some(None), + Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None), + Some(Value::String(b)) => Some(Some(b.clone())), + Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")), + }; + s.edit(|d, _| { + if let Some(Some(b)) = &based { + if d.styles.char_style(b).is_none() { + return Err(bad("style.character.edit", format!("no character style `{b}`"))); + } + if d.styles.char_based_on_cycles(&name, b) { + return Err(bad("style.character.edit", "based-on would create a cycle")); + } + } + if let Some(n) = &rename { + if n.is_empty() || n.starts_with('[') { + return Err(bad("style.character.edit", format!("can't rename to `{n}`"))); + } + if d.styles.char_style(n).is_some() { + return Err(bad("style.character.edit", format!("a character style named `{n}` already exists"))); + } + } + let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?; + st.chars.merge(&chars); + for k in &unset { + st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?; + } + if let Some(b) = based.clone() { + st.based_on = b; + } + if let Some(n) = &rename { + // Every use: other styles, stories, table cells, nested and GREP styles, footnotes. + rename_style(d, false, &name, n); + } + ok() + }) +} + +/// #777: the params descriptions name the attributes, and a wrong one is the command's error. +#[cfg(test)] +mod attribute_error_tests { + use super::*; + use crate::cmd::find_command; + + #[test] + fn a_wrong_attribute_names_the_command_and_the_right_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let err = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_err().to_string(); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": {"font": "Source Sans 3"}})), + "invalid parameters for `style.paragraph.create`: chars: unknown attribute `font`; did you mean `fontFamily`?" + ); + assert_eq!( + err(&mut s, "style.paragraph.create", json!({"name": "H", "para": {"spaceBefor": 6}})), + "invalid parameters for `style.paragraph.create`: para: unknown attribute `spaceBefor`; did you mean `spaceBefore`?" + ); + assert!( + err(&mut s, "style.character.create", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.create`: chars:") + ); + assert!(err(&mut s, "style.paragraph.create", json!({"name": "H", "chars": "bold"})).contains("chars must be an object of attributes")); + s.execute("style.paragraph.create", &json!({"name": "H", "chars": {"fontFamily": "Source Sans 3", "size": 14}})).unwrap(); + assert!( + err(&mut s, "style.paragraph.edit", json!({"name": "H", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.paragraph.edit`: chars:") + ); + s.execute("style.character.create", &json!({"name": "C", "chars": {"fontStyle": "Bold"}})).unwrap(); + assert!( + err(&mut s, "style.character.edit", json!({"name": "C", "chars": {"font": "X"}})) + .starts_with("invalid parameters for `style.character.edit`: chars:") + ); + for id in ["style.paragraph.create", "style.paragraph.edit", "style.character.create", "style.character.edit"] { + let doc = find_command(id).unwrap().params; + assert!(doc.contains("as type.char names them: fontFamily"), "{id}: {doc}"); + } + assert!(find_command("style.paragraph.create").unwrap().params.contains("as type.para names them: align")); + } +} + +#[cfg(test)] +mod export_tag_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn export_tagging_shapes_epub_markup() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter Title", "chars": {"size": 24}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Chapter Title", "tag": "h1", "class": "chapter"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "tag": "strong"})).unwrap(); + assert!(s.execute("style.exportTag", &json!({"style": "Nope"})).is_err()); + // Split Document is for paragraph styles; it alone keeps the tagging. + assert!(s.execute("style.exportTag", &json!({"style": "Key Term", "character": true, "split": true})).is_err()); + s.execute("style.paragraph.create", &json!({"name": "Part"})).unwrap(); + assert_eq!(s.execute("style.exportTag", &json!({"style": "Part", "split": true})).unwrap()["split"], true); + assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_some_and(|t| t.split)); + s.execute("style.exportTag", &json!({"style": "Part", "split": false})).unwrap(); + assert!(s.doc().unwrap().doc.styles.export_tag("Part", false).is_none(), "an untouched tagging goes"); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Origins\nA word"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Chapter Title"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 10, "focus": 14})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Key Term"})).unwrap(); + let html = s.execute("file.exportHtml", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(html.contains("

Origins

"), "{html}"); + assert!(html.contains("word"), "{html}"); + // Tagged PDF: a heading element and a paragraph element. + let r = s.execute("file.exportPdf", &json!({"tagged": true, "compressImages": false})).unwrap(); + let pdf = crate::cmd::file::base64_decode(r["base64"].as_str().unwrap()); + let text = String::from_utf8_lossy(&pdf); + assert!(text.contains("/S/H1") && text.contains("/S/P"), "structure elements"); + assert!(designcraft_render::pdf_page_count(&pdf) == Some(1)); + } + + #[test] + fn export_tagging_follows_a_renamed_style_and_goes_with_a_deleted_one() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Term"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Head", "tag": "h2", "class": "head"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Body", "tag": "p", "class": "body"})).unwrap(); + s.execute("style.exportTag", &json!({"style": "Term", "character": true, "tag": "em", "class": "term"})).unwrap(); + let tag = |s: &Session, name: &str, character: bool| { + s.doc().unwrap().doc.styles.export_tag(name, character).map(|t| (t.tag.clone(), t.class.clone())) + }; + let pair = |t: &str, c: &str| Some((t.to_string(), c.to_string())); + // A rename onto another style's name is refused, so neither tagging is lost. + assert!(s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Body"})).is_err()); + assert_eq!((tag(&s, "Head", false), tag(&s, "Body", false)), (pair("h2", "head"), pair("p", "body"))); + + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": "Title"})).unwrap(); + s.execute("style.character.edit", &json!({"name": "Term", "rename": "Word"})).unwrap(); + assert_eq!(tag(&s, "Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Word", true), pair("em", "term")); + assert_eq!(tag(&s, "Term", true), None); + // Moving a style into a group renames it to Group/Name. + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Title"], "group": "Heads"})).unwrap(); + assert_eq!(tag(&s, "Heads/Title", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(tag(&s, "Head", false), pair("h2", "head")); + assert_eq!(tag(&s, "Title", false), None); + assert_eq!(tag(&s, "Term", true), pair("em", "term")); + assert_eq!(tag(&s, "Word", true), None); + + // Deleting drops the deleted style's tagging; the replacement keeps its own. + s.execute("style.paragraph.delete", &json!({"name": "Head", "replaceWith": "Body"})).unwrap(); + assert_eq!(tag(&s, "Head", false), None); + assert_eq!(tag(&s, "Body", false), pair("p", "body")); + s.execute("style.character.delete", &json!({"name": "Term"})).unwrap(); + assert_eq!(tag(&s, "Term", true), None); + assert!(s.doc().unwrap().doc.styles.export_tags.keys().all(|k| k == "p:Body")); + } + + #[test] + fn a_rename_onto_a_taken_name_says_so_and_a_trimmed_rename_drops_a_stale_tag() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (kind, a, b) in [("paragraph", "Head", "Body"), ("character", "Term", "Word")] { + s.execute(&format!("style.{kind}.create"), &json!({"name": a})).unwrap(); + s.execute(&format!("style.{kind}.create"), &json!({"name": b})).unwrap(); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": b})).unwrap_err().to_string(); + assert!(e.contains(&format!("a {kind} style named `{b}` already exists")), "{e}"); + let e = s.execute(&format!("style.{kind}.edit"), &json!({"name": a, "rename": "[None]"})).unwrap_err().to_string(); + assert!(e.contains("can't rename to `[None]`"), "{e}"); + } + // An entry left under a name no style has doesn't attach to a style renamed onto it. + s.edit(|d, _| { + d.styles_mut() + .export_tags + .insert("p:Title".into(), designcraft_doc::ExportTag { tag: "h6".into(), class: "stale".into(), ..Default::default() }); + Ok(()) + }) + .unwrap(); + s.execute("style.paragraph.edit", &json!({"name": "Head", "rename": " Title "})).unwrap(); + let st = &s.doc().unwrap().doc.styles; + assert!(st.para("Title").is_some(), "the new name is trimmed"); + assert_eq!(st.export_tag("Title", false), None); + } +} + +#[cfg(test)] +mod cjk_tests { + use super::*; + #[test] + fn cjk_composite_font_command_invalidates_layout_and_undo_restores_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 150], "content": "text", "text": "AB"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let definition = |scale| json!({"name": "Mixed", "entries": [{"family": "Source Serif 4", "relativeSize": scale}]}); + s.execute("style.compositeFont.set", &definition(1.0)).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 2})).unwrap(); + s.execute("type.char", &json!({"fontFamily": "Mixed", "leadingAki": 0.25, "jidori": 4})).unwrap(); + let old = s.cache.get(&s.doc().unwrap().doc, sid, None); + s.execute("style.compositeFont.set", &definition(2.0)).unwrap(); + let new = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(!std::sync::Arc::ptr_eq(&old, &new)); + assert!(new.frames[0].lines[0].end_x > old.frames[0].lines[0].end_x); + s.execute("edit.undo", &json!({})).unwrap(); + let restored = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert_eq!(old.frames[0].lines[0].end_x, restored.frames[0].lines[0].end_x); + assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err()); + } +} + +#[cfg(test)] +mod delete_tests { + use designcraft_doc::{CellStyle, ObjectStyle, StoryId, Toc, TocEntry}; + use serde_json::json; + + use crate::Session; + + /// Is `name` still named anywhere in the document? + fn named(s: &Session, name: &str) -> bool { + serde_json::to_string(&*s.doc().unwrap().doc).unwrap().contains(&format!("\"{name}\"")) + } + + fn char_doc() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Emphasis", "basedOn": "Base", "chars": {"tracking": 20}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Child", "basedOn": "Emphasis"})).unwrap(); + s.execute("style.character.create", &json!({"name": "Strong", "chars": {"size": 14}})).unwrap(); + let refs = json!({ + "nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}], + "nestedLineStyles": [{"style": "Emphasis", "lines": 1}], + "dropCapLines": 2, "dropCapChars": 1, "dropCapStyle": "Emphasis"}); + s.execute("style.paragraph.create", &json!({"name": "Lead", "para": refs})).unwrap(); + s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap(); + let sid = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap()["story"] + .as_u64() + .unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap(); + // Local paragraph overrides name it too. + s.execute("type.para", &json!({"attrs": refs})).unwrap(); + s.edit(|d, _| { + d.styles_mut().default_character = "Emphasis".into(); + Ok(()) + }) + .unwrap(); + assert!(named(&s, "Emphasis")); + (s, sid) + } + + #[test] + fn deleting_a_character_style_points_every_use_at_the_replacement() { + let (mut s, sid) = char_doc(); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Strong"})).unwrap(); + assert!(!named(&s, "Emphasis"), "no dangling reference"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Strong")); + assert_eq!(d.story(StoryId(sid)).unwrap().chars[0].format.style, "Strong"); + let lead = &d.styles.para("Lead").unwrap().para; + assert_eq!(lead.nested_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.grep_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.nested_line_styles.as_ref().unwrap()[0].style, "Strong"); + assert_eq!(lead.drop_cap_style.as_deref(), Some("Strong")); + assert_eq!(d.footnote_options.ref_char_style, "Strong"); + assert_eq!(d.styles.default_character, "Strong"); + // One undo step brings the style and every use back. + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.styles.char_style("Emphasis").is_some()); + assert_eq!(s.doc().unwrap().doc.story(StoryId(sid)).unwrap().chars[0].format.style, "Emphasis"); + } + + #[test] + fn deleting_a_character_style_for_none_unsets_based_on() { + let (mut s, _) = char_doc(); + s.execute("style.character.delete", &json!({"name": "Emphasis"})).unwrap(); + assert!(!named(&s, "Emphasis"), "no dangling reference"); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.styles.char_style("Child").unwrap().based_on, None); + assert_eq!(d.footnote_options.ref_char_style, designcraft_doc::NO_CHAR_STYLE); + } + + /// Replacing a style with its own descendant rebases the deleted style's children on its + /// parent, so no based-on chain becomes a cycle. + #[test] + fn replacing_a_style_with_its_child_makes_no_cycle() { + let (mut s, _) = char_doc(); + s.execute("style.character.create", &json!({"name": "Grandchild", "basedOn": "Child"})).unwrap(); + s.execute("style.character.delete", &json!({"name": "Emphasis", "replaceWith": "Grandchild"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(d.styles.char_style("Child").unwrap().based_on.as_deref(), Some("Base")); + assert_eq!(d.styles.char_style("Grandchild").unwrap().based_on.as_deref(), Some("Child")); + assert!(!named(&s, "Emphasis")); + } + + #[test] + fn bad_style_deletes_are_errors_and_change_nothing() { + let (mut s, _) = char_doc(); + s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for (id, p) in [ + ("style.character.delete", json!({})), + ("style.character.delete", json!({"name": ""})), + ("style.character.delete", json!({"name": "Nope"})), + ("style.character.delete", json!({"name": "[None]"})), + ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Nope"})), + ("style.character.delete", json!({"name": "Emphasis", "replaceWith": "Emphasis"})), + ("style.paragraph.delete", json!({})), + ("style.paragraph.delete", json!({"name": ""})), + ("style.paragraph.delete", json!({"name": "Nope"})), + ("style.paragraph.delete", json!({"name": "[Basic Paragraph]"})), + ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Nope"})), + ("style.paragraph.delete", json!({"name": "Head", "replaceWith": "Head"})), + ] { + assert!(s.execute(id, &p).is_err(), "{id} {p}"); + } + assert_eq!(*s.doc().unwrap().doc, *before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn deleting_a_paragraph_style_points_every_use_at_the_replacement() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Table Body", "nextStyle": "Table Body"})).unwrap(); + s.edit(|d, _| { + let story = *d.stories.keys().next().ok_or(crate::EngineError::NoDocument)?; + let st = d.styles_mut(); + st.default_paragraph = "Table Body".into(); + st.object.push(ObjectStyle { name: "Boxed".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); + st.cell.push(CellStyle { name: "Body Cell".into(), paragraph_style: Some("Table Body".into()), ..Default::default() }); + d.footnote_options.para_style = "Table Body".into(); + d.endnote_options.para_style = "Table Body".into(); + d.endnote_options.heading_style = "Table Body".into(); + d.settings.epub_split = designcraft_doc::EpubSplit::Style("Table Body".into()); + d.toc = Some(Toc { + story, + title: "Contents".into(), + entries: vec![TocEntry { entry_style: Some("Table Body".into()), ..TocEntry::new("Table Body", 1) }], + page_numbers: false, + title_style: Some("Table Body".into()), + }); + Ok(()) + }) + .unwrap(); + assert!(named(&s, "Table Body")); + s.execute("style.paragraph.delete", &json!({"name": "Table Body", "replaceWith": "Body"})).unwrap(); + assert!(!named(&s, "Table Body"), "no dangling reference"); + let d = s.doc().unwrap().doc.clone(); + let sub = d.styles.para("Sub").unwrap(); + assert_eq!((sub.based_on.as_deref(), sub.next_style.as_deref()), (Some("Body"), Some("Body"))); + assert_eq!(d.styles.default_paragraph, "Body"); + assert_eq!(d.endnote_options.heading_style, "Body"); + assert_eq!(d.settings.epub_split, designcraft_doc::EpubSplit::Style("Body".into()), "EPUB Split Document follows the replacement"); + let after = s.execute("preflight.run", &json!({})).unwrap(); + assert!(!after.to_string().contains("Table Body"), "{after}"); + } +} + +#[cfg(test)] +mod grep_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn grep_style_pattern_that_cannot_compile_is_rejected() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); + let bad = json!([{"style": "Keep", "pattern": "(\\d+"}]); + let e = s.execute("style.paragraph.create", &json!({"name": "Broken", "para": {"grepStyles": bad}})).unwrap_err().to_string(); + assert!(e.contains("bad GREP") && e.contains("(\\\\d+"), "{e}"); + assert!(s.doc().unwrap().doc.styles.para("Broken").is_none()); + + // The lookbehind dialect compiles. + let good = json!([{"style": "Keep", "pattern": "(?<=\\s)[aiouwzAIOUWZ]\\s"}]); + s.execute("style.paragraph.create", &json!({"name": "Polish", "para": {"grepStyles": good}})).unwrap(); + let e = s.execute("style.paragraph.edit", &json!({"name": "Polish", "para": {"grepStyles": bad, "spaceAfter": 6}})).unwrap_err().to_string(); + assert!(e.contains("bad GREP"), "{e}"); + let st = s.doc().unwrap().doc.styles.para("Polish").unwrap().clone(); + assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "(?<=\\s)[aiouwzAIOUWZ]\\s"); + assert_eq!(st.para.space_after, None, "nothing changes"); + + // A paragraph override too. + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Ala i kot"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let e = s.execute("type.para", &json!({"grepStyles": bad})).unwrap_err().to_string(); + assert!(e.contains("bad GREP"), "{e}"); + } + + #[test] + fn idml_import_keeps_a_broken_grep_style_and_warns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Keep"})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Broken"})).unwrap(); + // Written past the command, as another application could. + s.edit(|d, _| { + if let Some(st) = d.styles_mut().para_mut("Broken") { + st.para.grep_styles = Some(vec![designcraft_doc::GrepStyle { style: "Keep".into(), pattern: "[a-".into() }]); + } + Ok(()) + }) + .unwrap(); + let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + let r = s.execute("file.openIdml", &json!({"base64": b64})).unwrap(); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("Broken") && warnings.contains("[a-") && warnings.contains("bad GREP"), "{warnings}"); + let st = s.doc().unwrap().doc.styles.para("Broken").unwrap().clone(); + assert_eq!(st.para.grep_styles.unwrap()[0].pattern, "[a-", "the style opens with its pattern"); + } +} diff --git a/crates/engine/src/cmd/style/swatches.rs b/crates/engine/src/cmd/style/swatches.rs new file mode 100644 index 000000000..3b73138f9 --- /dev/null +++ b/crates/engine/src/cmd/style/swatches.rs @@ -0,0 +1,1002 @@ +use super::color::check_text_nesting; +use super::{Result, Session, Styles, Swatch, SwatchValue, Value, bad, json, str_param}; + +pub(super) fn create_swatch(s: &mut Session, p: &Value) -> Result { + if let Some(g) = p.get("gradient") { + return create_gradient_swatch(s, p, g); + } + let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color (or gradient)"))?; + let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?; + let spot = p.get("spot").and_then(Value::as_bool).unwrap_or(false); + let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| value_name(color)); + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); + d.swatches.push(Swatch { + name: name.clone(), + value: SwatchValue::Color { + color, + color_type: if spot { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }, + }, + locked: false, + named: true, + hidden: false, + }); + Ok(json!({"name": name})) + }) +} + +/// `swatch.create {gradient}`: a named gradient swatch. Stops take a colour swatch by name (as a +/// swatch dragged onto the Gradient panel's stop) or a colour. +fn create_gradient_swatch(s: &mut Session, p: &Value, g: &Value) -> Result { + use designcraft_color::{Gradient, GradientKind, GradientStop}; + const ID: &str = "swatch.create"; + let kind = match g.get("kind").and_then(Value::as_str) { + None | Some("linear") => GradientKind::Linear, + Some("radial") => GradientKind::Radial, + Some(k) => return Err(bad(ID, format!("unknown gradient kind {k}"))), + }; + let doc = &s.doc()?.doc; + let mut stops = Vec::new(); + for st in g.get("stops").and_then(Value::as_array).ok_or_else(|| bad(ID, "a gradient needs stops"))? { + // The stop keeps the swatch, so print export writes it in that swatch's inks. + let (color, swatch) = match (st.get("swatch").and_then(Value::as_str), st.get("color")) { + (Some(n), _) => match doc.swatch(n).map(|w| &w.value) { + Some(SwatchValue::Gradient { .. }) => return Err(bad(ID, format!("`{n}` is a gradient; a stop takes a colour"))), + Some(_) => (doc.resolve_color(n, 1.0).ok_or_else(|| bad(ID, format!("`{n}` has no color")))?, Some(n.to_string())), + None => return Err(bad(ID, format!("no swatch named `{n}`"))), + }, + (None, Some(c)) => (parse_color(c).ok_or_else(|| bad(ID, "a stop has a bad color"))?, None), + (None, None) => return Err(bad(ID, "a stop needs a swatch or a color")), + }; + let pct = |k: &str, d: f64| st.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d).clamp(0.0, 100.0) as f32 / 100.0; + stops.push(GradientStop { + offset: pct("location", 0.0), + color, + opacity: pct("opacity", 100.0), + midpoint: pct("midpoint", 50.0).clamp(0.13, 0.87), + swatch, + }); + } + if stops.len() < 2 { + return Err(bad(ID, "a gradient needs at least two stops")); + } + let mut gradient = Gradient { kind, stops }; + gradient.sort(); + let name = str_param(p, "name").unwrap_or("New Gradient Swatch").to_string(); + s.edit(|d, _| { + let name = Styles::unique_name(|n| d.swatch(n).is_some(), &name); + d.swatches.push(Swatch { name: name.clone(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + Ok(json!({"name": name})) + }) +} + +/// `swatch.new`: InDesign's New Swatch. An unnamed colour or gradient (the selection's, or +/// `swatch`) is added to the swatches under a name, and stays applied; a named swatch is +/// duplicated as " copy". +pub(super) fn new_swatch(s: &mut Session, p: &Value) -> Result { + const ID: &str = "swatch.new"; + let st = s.doc()?; + let name = match str_param(p, "swatch") { + Some(n) => n.to_string(), + None => { + let Some(id) = st.selection.items.first().copied() else { + // Nothing selected: a new colour swatch. + return create_swatch(s, &json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); + }; + let it = st.doc.item(id).ok_or_else(|| bad(ID, "no such item"))?; + if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } + } + }; + let w = st.doc.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch named `{name}`")))?; + if matches!(w.value, SwatchValue::None | SwatchValue::Registration) { + return Err(bad(ID, format!("`{name}` can't be made a new swatch"))); + } + if w.hidden { + // An unnamed gradient takes InDesign's "New Gradient Swatch"; an unnamed colour its values. + let new = match w.value { + SwatchValue::Gradient { .. } => Styles::unique_name(|n| st.doc.swatch(n).is_some(), "New Gradient Swatch"), + _ => name.clone(), + }; + return add_to_swatches(s, &json!({"swatch": name, "name": new})); + } + s.edit(|d, _| { + let copy = Styles::unique_name(|n| d.swatch(n).is_some(), &format!("{name} copy")); + d.swatches.push(Swatch { name: copy.clone(), locked: false, named: true, hidden: false, ..w.clone() }); + Ok(json!({"name": copy})) + }) +} + +pub fn parse_color(v: &Value) -> Option { + use designcraft_color::Color; + match v { + Value::String(s) => Color::from_hex(s), + // `{model, …}` is the colour's own form (as `color.convert` takes it): fractions, never guessed. + Value::Object(o) if o.contains_key("model") => model_color(v), + // One scale per colour, not per component: [1, 128, 255] is 0..255. Only an RGB array whose + // components are all within 0..1 is read as fractions (the older form). + Value::Array(a) if a.len() >= 3 => { + let v = [a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?]; + let scale = if v.iter().any(|&x| x > 1.0) { 255.0 } else { 1.0 }; + Some(Color::rgb((v[0] / scale) as f32, (v[1] / scale) as f32, (v[2] / scale) as f32)) + } + // Bare CMYK is always 0..100, as documented: C 1 % is a real tint, so values of 1 or less can't + // mean fractions (#910). + Value::Object(o) => { + if o.contains_key("gray") { + let gray = o.get("gray").and_then(Value::as_f64)?; + return Some(Color::gray(if gray > 1.0 { gray / 100.0 } else { gray } as f32)); + } + let g = |k: &str| o.get(k).and_then(Value::as_f64).map(|x| (x / 100.0) as f32); + Some(Color::cmyk(g("c")?, g("m")?, g("y")?, g("k")?)) + } + _ => None, + } +} + +/// `{model: rgb|cmyk|gray, …}` with every component in 0..=1; anything else is not a colour. +fn model_color(v: &Value) -> Option { + use designcraft_color::Color; + let color: Color = serde_json::from_value(v.clone()).ok()?; + // A float's last bit (100.0 / 100.0 ≠ 1.0 after an f32 round trip) is not out of range. + let unit = |x: f32| x.is_finite() && (-1e-4..=1.0 + 1e-4).contains(&x); + let q = |x: f32| x.clamp(0.0, 1.0); + match color { + Color::Rgb { r, g, b } => [r, g, b].into_iter().all(unit).then(|| Color::rgb(q(r), q(g), q(b))), + Color::Cmyk { c, m, y, k } => [c, m, y, k].into_iter().all(unit).then(|| Color::cmyk(q(c), q(m), q(y), q(k))), + Color::Gray { k } => unit(k).then_some(Color::Gray { k: q(k) }), + } +} + +/// The swatch name of a colour value ("C=… M=… Y=… K=…" / "R=… G=… B=…"). +pub(super) fn value_name(color: designcraft_color::Color) -> String { + designcraft_color::swatch::value_name(color) +} + +pub(super) fn load_swatches(s: &mut Session, p: &Value) -> Result { + let bytes = match (str_param(p, "path"), str_param(p, "base64")) { + (_, Some(b)) => crate::cmd::file::base64_decode(b), + (Some(path), None) => crate::io::read_file(path).map_err(|e| bad("swatch.load", e))?, + _ => return Err(bad("swatch.load", "give `path` or `base64`")), + }; + let incoming = designcraft_color::ase::read(&bytes).map_err(|e| bad("swatch.load", e.to_string()))?; + let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(false); + s.edit(|d, _| { + // The position of the first swatch with each name, kept up to date as the file's colors are added. + let mut at: std::collections::HashMap = std::collections::HashMap::new(); + for (i, x) in d.swatches.iter().enumerate() { + at.entry(x.name.clone()).or_insert(i); + } + let mut names = FileNames::default(); + let (mut added, mut replaced) = (0, 0); + for mut w in incoming { + // An unnamed color is named by its value, and a name the file already gave gets a number. + if w.name.is_empty() + && let SwatchValue::Color { color, .. } = w.value + { + w.name = value_name(color); + w.named = false; + } + w.name = names.unique(std::mem::take(&mut w.name)); + match at.get(&w.name).and_then(|&i| d.swatches.get_mut(i)) { + Some(x) if x.locked => {} + Some(x) => { + if replace { + x.value = w.value; + x.hidden = false; + replaced += 1; + } + } + None => { + at.insert(w.name.clone(), d.swatches.len()); + d.swatches.push(w); + added += 1; + } + } + } + Ok(json!({"added": added, "replaced": replaced})) + }) +} + +/// The swatch names one `.ase` file has given. A name already given gets the first free number +/// ("Red 2"), as [`Styles::unique_name`] numbers names. +#[derive(Default)] +struct FileNames { + taken: std::collections::HashSet, + /// The last number tried for each repeated name. + last: std::collections::HashMap, +} + +impl FileNames { + fn unique(&mut self, base: String) -> String { + if self.taken.insert(base.clone()) { + return base; + } + // Each pass tries the next number, and only finitely many names are taken. + let n = self.last.entry(base.clone()).or_insert(1); + loop { + *n += 1; + let name = format!("{base} {n}"); + if self.taken.insert(name.clone()) { + return name; + } + } + } +} + +pub(super) fn save_swatches(s: &mut Session, p: &Value) -> Result { + let d = &s.doc()?.doc; + let names: Option> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()); + let chosen: Vec = d.swatches.iter().filter(|w| names.as_ref().is_none_or(|n| n.contains(&w.name))).cloned().collect(); + let bytes = designcraft_color::ase::write(&chosen); + match str_param(p, "path") { + Some(path) => { + crate::io::write_file(path, &bytes).map_err(|e| bad("swatch.save", e))?; + Ok(json!({"path": path, "bytes": bytes.len()})) + } + None => Ok(json!({"base64": crate::cmd::file::base64_encode(&bytes)})), + } +} + +pub(super) fn add_to_swatches(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let name = match str_param(p, "swatch") { + Some(n) => n.to_string(), + None => { + let id = st.selection.items.first().copied().ok_or_else(|| bad("swatch.addToSwatches", "select an object or give `swatch`"))?; + let it = st.doc.item(id).ok_or_else(|| bad("swatch.addToSwatches", "no such item"))?; + if str_param(p, "target") == Some("stroke") { it.stroke.swatch.clone() } else { it.fill.swatch.clone() } + } + }; + let rename = str_param(p, "name").map(str::to_string); + let result = s.edit(|d, _| { + let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; + if w.locked { + return Err(bad("swatch.addToSwatches", "special swatches are already listed")); + } + w.hidden = false; + let Some(new) = rename.filter(|n| *n != name) else { return Ok(json!({"name": name})) }; + if d.swatches.iter().any(|w| w.name == new) { + return Err(bad("swatch.addToSwatches", format!("a swatch named `{new}` exists"))); + } + check_text_nesting(d, "swatch.addToSwatches")?; + let w = d.swatches.iter_mut().find(|w| w.name == name).ok_or_else(|| bad("swatch.addToSwatches", format!("no swatch `{name}`")))?; + w.name = new.clone(); + w.named = true; + d.rename_swatch_refs(&name, &new); + Ok(json!({"name": new})) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + crate::cmd::text::rename_typing_swatch(s, &name, new); + } + Ok(result) +} + +/// Swatch Options: rename, Name with Color Value, colour type and value, a tint swatch's tint +/// and base — one undo step. Every use of the swatch follows a rename. +pub(super) fn swatch_options(s: &mut Session, p: &Value) -> Result { + const ID: &str = "swatch.options"; + let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?.to_string(); + let to = str_param(p, "to").map(|n| n.trim().to_string()); + let with_value = p.get("nameWithValue").and_then(Value::as_bool); + let color = match p.get("color") { + Some(c) => Some(parse_color(c).ok_or_else(|| bad(ID, "bad color"))?), + None => None, + }; + let spot = p.get("spot").and_then(Value::as_bool); + let tint = match p.get("tint") { + Some(v) => Some(v.as_f64().filter(|t| t.is_finite() && (0.0..=1.0).contains(t)).ok_or_else(|| bad(ID, "tint is 0–1"))? as f32), + None => None, + }; + let base = str_param(p, "base").map(str::to_string); + let result = s.edit(|d, _| { + let w = d.swatch(&name).cloned().ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; + let mut w2 = w.clone(); + if w.locked { + if to.as_ref().is_some_and(|t| *t != name) || with_value == Some(true) { + return Err(bad(ID, format!("`{name}` can't be renamed"))); + } + match (&mut w2.value, color) { + (_, None) => {} + (SwatchValue::Paper { color: c }, Some(new)) => *c = new, + _ => return Err(bad(ID, format!("`{name}` can't be edited"))), + } + if spot.is_some() || tint.is_some() || base.is_some() { + return Err(bad(ID, format!("`{name}` can't be edited"))); + } + } else { + match &mut w2.value { + SwatchValue::Color { color: c, color_type } => { + if let Some(new) = color { + *c = new; + } + if let Some(sp) = spot { + *color_type = if sp { designcraft_color::ColorType::Spot } else { designcraft_color::ColorType::Process }; + } + if tint.is_some() || base.is_some() { + return Err(bad(ID, "only tint swatches have a tint")); + } + } + SwatchValue::Tint { base: b, tint: t } => { + if color.is_some() || spot.is_some() { + return Err(bad(ID, "a tint swatch takes its colour from its base")); + } + if let Some(v) = tint { + *t = v; + } + if let Some(nb) = &base { + if !d.swatch(nb).is_some_and(|x| matches!(x.value, SwatchValue::Color { .. })) || *nb == name { + return Err(bad(ID, format!("`{nb}` isn't a color swatch"))); + } + *b = nb.clone(); + } + } + _ => { + if color.is_some() || spot.is_some() || tint.is_some() || base.is_some() { + return Err(bad(ID, format!("`{name}` only takes a name here"))); + } + } + } + // Name with Color Value: the name is the colour's values and tracks later edits. + let auto = with_value.unwrap_or(!w.named && to.as_ref().is_none_or(|t| *t == name)); + let derived = match (&w2.value, auto) { + (SwatchValue::Color { color, .. }, true) => Some(value_name(*color)), + _ => None, + }; + match derived { + Some(n) => { + w2.name = n; + w2.named = false; + } + None => { + if let Some(t) = &to { + if t.is_empty() { + return Err(bad(ID, "a swatch needs a name")); + } + w2.name = t.clone(); + } + if with_value == Some(false) || w2.name != name { + w2.named = true; + } + } + } + if w2.name != name && d.swatch(&w2.name).is_some() { + return Err(bad(ID, format!("a swatch named `{}` exists", w2.name))); + } + } + let new = w2.name.clone(); + if new != name { + check_text_nesting(d, ID)?; + } + let slot = d.swatches.iter_mut().find(|x| x.name == name).ok_or_else(|| bad(ID, format!("no swatch `{name}`")))?; + *slot = w2; + d.rename_swatch_refs(&name, &new); + d.refresh_gradient_stops(); + Ok(json!({"name": new})) + })?; + if let Some(new) = result.get("name").and_then(Value::as_str) { + crate::cmd::text::rename_typing_swatch(s, &name, new); + } + Ok(result) +} + +#[cfg(test)] +mod group_sample_tests { + use serde_json::json; + + use crate::Session; + + /// Grouping the sample's table styles keeps its table cells styled (no missing styles). + #[test] + fn grouping_reaches_table_cells() { + let mut s = Session::new(); + s.execute("file.newSample", &json!({})).unwrap(); + let before = s.execute("preflight.run", &json!({})).unwrap()["errors"].as_u64().unwrap(); + s.execute("style.group", &json!({"kind": "paragraph", "names": ["Table Head", "Table Body"], "group": "Tables"})).unwrap(); + let after = s.execute("preflight.run", &json!({})).unwrap(); + assert_eq!(after["errors"].as_u64().unwrap(), before, "{after}"); + } +} + +#[cfg(test)] +mod color_group_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn color_groups_hold_swatches_and_round_trip() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("swatch.create", &json!({"name": "Brand Red", "color": {"c": 0, "m": 100, "y": 100, "k": 0}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let b = s.execute("swatch.create", &json!({"name": "Brand Blue", "color": {"c": 100, "m": 60, "y": 0, "k": 0}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let g = s.execute("swatch.newColorGroup", &json!({"name": "Brand", "swatches": [a]})).unwrap(); + assert_eq!(g["name"], "Brand"); + assert!(s.execute("swatch.newColorGroup", &json!({"swatches": ["[Black]"]})).is_err(), "special swatches stay at the top"); + s.execute("swatch.moveToGroup", &json!({"swatches": [b], "group": "Brand"})).unwrap(); + let groups = |s: &Session| s.doc().unwrap().doc.color_groups.clone(); + assert_eq!(groups(&s)[0].swatches, [a.clone(), b.clone()]); + let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); + let back = designcraft_idml::import_idml(&bytes).unwrap(); + assert_eq!(back.color_groups, groups(&s), "IDML ColorGroup"); + s.execute("swatch.delete", &json!({"name": b})).unwrap(); + assert_eq!(groups(&s)[0].swatches, [a.as_str()]); + s.execute("swatch.renameColorGroup", &json!({"name": "Brand", "to": "Identity"})).unwrap(); + s.execute("swatch.ungroupColorGroup", &json!({"name": "Identity"})).unwrap(); + assert!(groups(&s).is_empty()); + assert!(s.doc().unwrap().doc.swatch(&a).is_some(), "ungrouping keeps the swatches"); + } +} + +#[cfg(test)] +mod ink_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn ink_manager_converts_and_aliases_spots_in_pdf() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + for (n, c) in [("Pantone A", [0, 50, 100, 0]), ("Pantone B", [100, 0, 0, 0])] { + s.execute("swatch.create", &json!({"name": n, "spot": true, "color": {"c": c[0], "m": c[1], "y": c[2], "k": c[3]}})).unwrap(); + } + let id = s.execute("frame.create", &json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "Pantone A", "ids": [id]})).unwrap(); + let pdf = |s: &mut Session| { + let b = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].as_str().unwrap().to_string(); + String::from_utf8_lossy(&crate::cmd::file::base64_decode(&b)).into_owned() + }; + assert!(pdf(&mut s).contains("Pantone"), "a separation"); + s.execute("ink.options", &json!({"ink": "Pantone A", "alias": "Pantone B"})).unwrap(); + let p = pdf(&mut s); + assert!(p.contains("Pantone#20B") || p.contains("Pantone B"), "aliased onto B"); + assert!(!p.contains("Pantone#20A") && !p.contains("/Pantone A")); + s.execute("ink.options", &json!({"allToProcess": true})).unwrap(); + assert!(!pdf(&mut s).contains("Pantone"), "all spots to process"); + let l = s.execute("ink.list", &json!({})).unwrap(); + assert_eq!(l["inks"][0]["alias"], "Pantone B"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + assert!(designcraft_pdf::inks::spot_output(&back, &designcraft_pdf::inks::ink_set(&back), "Pantone A").1, "IDML ConvertToProcess"); + assert!(back.inks.aliases.iter().any(|(a, b)| a == "Pantone A" && b == "Pantone B"), "IDML AliasInkName"); + assert!(s.execute("ink.options", &json!({"ink": "[Black]", "toProcess": true})).is_err()); + assert!(s.execute("ink.options", &json!({"ink": "Pantone B", "alias": "Pantone A"})).is_err(), "an alias loop is refused"); + } +} + +#[cfg(test)] +mod swatch_options_tests { + use designcraft_color::SwatchValue; + use designcraft_doc::{ItemId, StoryId}; + use serde_json::json; + + use crate::Session; + + /// A document whose frame, text, styles, tint swatch and colour group all use "Brand". + fn brand_doc() -> (Session, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 100, "k": 0}})).unwrap(); + s.execute("swatch.create", &json!({"name": "Other", "color": "#108080"})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap(); + let (id, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + s.execute("object.fill", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); + s.execute("object.stroke", &json!({"swatch": "Brand", "ids": [id]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fill": "Brand"}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5, "focus": 5})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.select", &json!({"what": "table"})).unwrap(); + s.execute("table.setCell", &json!({"stroke": {"weight": 2, "type": "dashed", "gapColor": "Brand", "edges": ["all"]}})).unwrap(); + s.execute("swatch.newColorGroup", &json!({"name": "Brand Colors", "swatches": ["Brand"]})).unwrap(); + s.execute("style.character.create", &json!({"name": "Accent"})).unwrap(); + s.edit(|d, _| { + d.swatches.push(designcraft_color::Swatch { + name: "Brand 50%".into(), + value: SwatchValue::Tint { base: "Brand".into(), tint: 0.5 }, + locked: false, + named: true, + hidden: false, + }); + let st = d.styles_mut(); + if let Some(p) = st.para_mut(designcraft_doc::BASIC_PARAGRAPH) { + p.chars.underline_color = Some("Brand".into()); + p.para.shading_color = Some("Brand".into()); + p.para.border_gap_color = Some("Brand".into()); + p.chars.ruby_stroke = Some("Brand".into()); + } + if let Some(c) = st.char_style_mut("Accent") { + c.chars.fill = Some("Brand".into()); + c.chars.kenten_fill = Some("Brand".into()); + } + Ok(()) + }) + .unwrap(); + (s, id, sid) + } + + fn uses(s: &Session, id: u64, sid: u64, name: &str) -> Vec { + let d = &s.doc().unwrap().doc; + let it = d.item(ItemId(id)).unwrap(); + let story = d.story(StoryId(sid)).unwrap(); + let basic = d.styles.para(designcraft_doc::BASIC_PARAGRAPH).unwrap(); + vec![ + it.fill.swatch == name, + it.stroke.swatch == name, + story.chars.iter().any(|r| r.format.over.fill.as_deref() == Some(name)), + basic.chars.underline_color.as_deref() == Some(name), + basic.para.shading_color.as_deref() == Some(name), + d.styles.char_style("Accent").unwrap().chars.fill.as_deref() == Some(name), + matches!(&d.swatch("Brand 50%").unwrap().value, SwatchValue::Tint { base, .. } if base == name), + d.color_groups[0].swatches == [name], + basic.para.border_gap_color.as_deref() == Some(name), + basic.chars.ruby_stroke.as_deref() == Some(name), + d.styles.char_style("Accent").unwrap().chars.kenten_fill.as_deref() == Some(name), + story.tables.values().any(|t| t.cells.iter().all(|c| c.strokes.iter().all(|e| e.gap_color == name))), + ] + } + + #[test] + fn renaming_a_swatch_renames_every_use_in_one_undo_step() { + let (mut s, id, sid) = brand_doc(); + assert!(uses(&s, id, sid, "Brand").iter().all(|u| *u)); + let r = s.execute("swatch.options", &json!({"name": "Brand", "to": "Signal Orange", "spot": true})).unwrap(); + assert_eq!(r["name"], "Signal Orange"); + let d = &s.doc().unwrap().doc; + assert!(d.swatch("Brand").is_none()); + assert!(matches!(d.swatch("Signal Orange").unwrap().value, SwatchValue::Color { color_type: designcraft_color::ColorType::Spot, .. })); + assert_eq!(uses(&s, id, sid, "Signal Orange"), vec![true; 12]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("Brand").is_some()); + assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); + } + + #[test] + fn bad_swatch_renames_change_nothing() { + let (mut s, id, sid) = brand_doc(); + let before = s.doc().unwrap().doc.clone(); + for p in [ + json!({"name": "Brand", "to": "Other"}), + json!({"name": "Brand", "to": " "}), + json!({"name": "Brand", "to": "[Paper]"}), + json!({"name": "[Black]", "to": "Ink"}), + json!({"name": "[Registration]", "to": "Reg"}), + json!({"name": "[None]", "to": "Nothing"}), + json!({"name": "[Black]", "color": "#ff0000"}), + json!({"name": "Brand 50%", "color": "#ff0000"}), + json!({"name": "Missing", "to": "X"}), + ] { + assert!(s.execute("swatch.options", &p).is_err(), "{p}"); + } + assert_eq!(s.doc().unwrap().doc.swatches, before.swatches); + assert_eq!(uses(&s, id, sid, "Brand"), vec![true; 12]); + // [Paper]'s colour can change; its name can't. + s.execute("swatch.options", &json!({"name": "[Paper]", "to": "[Paper]", "color": "#fff8e0"})).unwrap(); + } + + #[test] + fn name_with_color_value_tracks_the_colour() { + let (mut s, id, sid) = brand_doc(); + let r = s.execute("swatch.options", &json!({"name": "Brand", "nameWithValue": true, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); + assert_eq!(r["name"], "C=10 M=20 Y=30 K=40"); + assert_eq!(uses(&s, id, sid, "C=10 M=20 Y=30 K=40"), vec![true; 12]); + // Later colour edits keep the name in step until the swatch is named by hand. + let r = s.execute("swatch.options", &json!({"name": "C=10 M=20 Y=30 K=40", "color": {"c": 0, "m": 0, "y": 0, "k": 50}})).unwrap(); + assert_eq!(r["name"], "C=0 M=0 Y=0 K=50"); + let r = s.execute("swatch.options", &json!({"name": "C=0 M=0 Y=0 K=50", "to": "Warm Gray"})).unwrap(); + assert_eq!(r["name"], "Warm Gray"); + assert!(s.doc().unwrap().doc.swatch("Warm Gray").unwrap().named); + } +} + +#[cfg(test)] +mod sample_color_tests { + use designcraft_color::{Color, SwatchValue}; + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// A 2×1 image, red then blue, placed 144 pt wide at (72, 72): each pixel is 72 pt. + fn session_with_image() -> (Session, u64) { + let png = designcraft_render::Rendered { width: 2, height: 1, pixels: vec![255, 0, 0, 255, 0, 0, 255, 255] }.to_png(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let b64 = crate::cmd::file::base64_encode(&png); + let r = s.execute("file.place", &json!({"base64": b64, "name": "rb.png", "x": 72, "y": 72, "width": 144})).unwrap(); + (s, r["id"].as_u64().unwrap()) + } + + fn swatch_color(s: &Session, name: &str) -> Color { + match &s.doc().unwrap().doc.swatch(name).unwrap().value { + SwatchValue::Color { color, .. } => *color, + other => panic!("{other:?}"), + } + } + + #[test] + fn the_eyedropper_colours_selected_text_with_the_image_under_it() { + let (mut s, image) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Elevate."})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 7})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("color.sample", &json!({"at": [180, 100]})).unwrap(); + assert_eq!((r["color"].as_str(), r["from"].as_u64()), (Some("#0000ff"), Some(image)), "{r}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let name = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(swatch_color(&s, &name), Color::rgb8(0, 0, 255)); + assert!(s.doc().unwrap().doc.swatch(&name).unwrap().hidden, "an unnamed colour"); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(0).over.fill.as_deref(), Some(name.as_str())); + assert_eq!(st.format_after(7).over.fill, None, "the full stop keeps its colour"); + // The frame itself is not coloured. + assert!(s.doc().unwrap().doc.item(fid).unwrap().fill.is_none()); + } + + #[test] + fn the_eyedropper_fills_selected_objects_and_samples_locked_images() { + let (mut s, _) = session_with_image(); + let lid = s.doc().unwrap().doc.layers[0].id.0; + s.execute("layer.set", &json!({"id": lid, "locked": true})).unwrap(); + s.execute("layer.new", &json!({"name": "TEXT"})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + assert_eq!(r["color"], "#ff0000", "{r}"); + let it = s.doc().unwrap().doc.item(ItemId(rect)).unwrap().clone(); + assert_eq!(swatch_color(&s, &it.stroke.swatch), Color::rgb8(255, 0, 0)); + // Sampling the same colour again reuses its swatch. + let again = s.execute("color.sample", &json!({"at": [90, 90]})).unwrap(); + assert_eq!(again["swatch"], r["swatch"]); + } + + #[test] + fn a_caret_takes_the_sample_for_typing_and_nothing_to_sample_is_refused() { + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text", "text": "Hi"})).unwrap(); + let fid = ItemId(f["id"].as_u64().unwrap()); + let story = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + // A caret: the sampled colour is for the next typing. + let r = s.execute("color.sample", &json!({"at": [100, 100]})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fill"], r["swatch"]); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "Hi", "caret color waits for typing"); + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + let st = s.doc().unwrap().doc.story(story).unwrap().clone(); + assert_eq!(st.format_after(1).over.fill.as_deref(), r["swatch"].as_str(), "{r}"); + assert_eq!(st.format_after(0).over.fill, None, "the text before the caret keeps its colour"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let none = s.execute("color.sample", &json!({"at": [600, 600]})).unwrap_err().to_string(); + assert!(none.contains("no placed image"), "{none}"); + // Just past the image's right edge. + assert!(s.execute("color.sample", &json!({"at": [216.5, 100]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + + #[test] + fn where_no_image_pixel_shows_the_clicked_frame_is_matched_instead() { + let (mut s, image) = session_with_image(); + let rect = s.execute("frame.create", &json!({"rect": [300, 300, 400, 400]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"swatch": "[Black]", "ids": [image]})).unwrap(); + // The frame reaches 72 pt below its image, and a clipping path shows the left (red) pixel only. + s.edit(|d, _| { + let it = d.item_mut(ItemId(image)).ok_or(crate::EngineError::NoDocument)?; + let b = it.inner_bounds(); + it.path = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1 + 72.0)); + let designcraft_doc::Content::Graphic(g) = &mut it.content else { return Err(crate::EngineError::NoDocument) }; + let left = designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, g.size.0 / 2.0, g.size.1)); + g.clip = Some(std::sync::Arc::new(designcraft_doc::ClippingPath::new(designcraft_doc::ClippingKind::PhotoshopPath, left))); + Ok(()) + }) + .unwrap(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "matchFrom": image})).unwrap(); + assert_eq!(r["color"], "#ff0000", "inside the clipping path the image is sampled"); + // The clipped-out blue pixel and the empty frame below the image: nothing to sample, so the + // frame's attributes apply. + for at in [[180, 100], [100, 180]] { + assert!(s.execute("color.sample", &json!({"at": at})).is_err(), "{at:?}"); + s.execute("object.fill", &json!({"swatch": "[Paper]", "ids": [rect]})).unwrap(); + s.execute("color.sample", &json!({"at": at, "matchFrom": image})).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(ItemId(rect)).unwrap().fill.swatch, "[Black]", "{at:?}"); + } + } + + #[test] + fn sampling_and_explicit_colour_use_the_same_text_application_path() { + for command in ["color.sample", "object.color"] { + // Each command gets its own key: the eyedropper samples at `at`, object.color takes `color`. + let params = |at: [f64; 2], color: &str, extra: serde_json::Value| { + let mut p = if command == "color.sample" { json!({"at": at}) } else { json!({"color": color}) }; + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + p + }; + let (mut s, _) = session_with_image(); + let f = s.execute("frame.create", &json!({"rect": [300, 300, 500, 360], "content": "text"})).unwrap(); + let story = designcraft_doc::StoryId(f["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(command, ¶ms([100.0, 100.0], "#ff0000", json!({}))).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{command}"); + s.execute("text.insert", &json!({"text": "Hi"})).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "empty-story typing: {command}"); + // Explicit ids override text targeting, including with a text selection active. + let fid = ItemId(f["id"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 2})).unwrap(); + let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"id": fid.0}))).unwrap(); + assert_eq!(s.doc().unwrap().doc.item(fid).unwrap().fill.swatch, r["swatch"].as_str().unwrap()); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_ne!(text.format_after(0).over.fill.as_deref(), r["swatch"].as_str(), "text unchanged: {command}"); + let r = s.execute(command, ¶ms([180.0, 100.0], "#0000ff", json!({"target": "stroke"}))).unwrap(); + let text = s.doc().unwrap().doc.story(story).unwrap(); + assert_eq!(text.format_after(0).over.stroke.as_deref(), r["swatch"].as_str(), "selected text stroke: {command}"); + } + } + + #[test] + fn sampling_with_the_stroke_in_front_colours_a_lines_stroke() { + let (mut s, _) = session_with_image(); + let line = s.execute("line.create", &json!({"a": [300, 300], "b": [500, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("selection.set", &json!({"ids": [line]})).unwrap(); + let r = s.execute("color.sample", &json!({"at": [100, 100], "target": "stroke"})).unwrap(); + let it = s.doc().unwrap().doc.item(ItemId(line)).unwrap().clone(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap(), "{r}"); + assert!(it.fill.is_none(), "the fill stays empty"); + } +} + +#[cfg(test)] +mod radial_gradient_tests { + use serde_json::json; + + /// White at the side and the top of a 100×200 radial (black → white) rectangle at (50, 20) on + /// a 200×240 page, rendered at 1 px/pt. + fn sides(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(100, 120)[0], img.pixel(52, 120)[0], img.pixel(100, 22)[0]) + } + + #[test] + fn a_radial_gradient_is_the_objects_ellipse_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [50, 20, 150, 220], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "swatch": "[None]"})).unwrap(); + s.execute( + "object.gradient", + &json!({"ids": [id], "kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut r = designcraft_render::Renderer::new(); + let (c, side, top) = sides(&r.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "screen: centre {c}, side {side}, top {top}"); + // The PDF, placed back and drawn: the same ellipse. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 240, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "radial.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + let (c, side, top) = sides(&r.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(c < 30 && side > 220 && top > 220, "pdf: centre {c}, side {side}, top {top}"); + } + + #[test] + fn a_box_with_no_height_or_width_has_a_circle_across_its_length() { + let f = designcraft_doc::Fill::swatch("g"); + let line = designcraft_geom::Rect::new(20.0, 60.0, 180.0, 60.0); + let (c, rx, ry) = f.radial_ellipse(line); + assert_eq!((c.x, c.y, rx, ry), (100.0, 60.0, 80.0, 80.0)); + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(10.0, 0.0, 10.0, 50.0)); + assert_eq!((rx, ry), (25.0, 25.0)); + // A point: a tiny circle, never a zero radius. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(5.0, 5.0, 5.0, 5.0)); + assert!(rx > 0.0 && rx == ry); + // A box keeps its ellipse. + let (_, rx, ry) = f.radial_ellipse(designcraft_geom::Rect::new(0.0, 0.0, 100.0, 200.0)); + assert_eq!((rx, ry), (50.0, 100.0)); + } +} + +#[cfg(test)] +mod gradient_swatch_tests { + use designcraft_color::SwatchValue; + use serde_json::json; + + fn doc() -> crate::Session { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s + } + + #[test] + fn a_gradient_swatch_from_colour_swatches() { + let mut s = doc(); + // As the Gradient panel's stops take swatches dragged onto them. + let r = s + .execute( + "swatch.create", + &json!({"gradient": {"stops": [{"location": 100, "swatch": "C=100 M=0 Y=0 K=0"}, {"location": 0, "swatch": "C=0 M=100 Y=0 K=0"}]}}), + ) + .unwrap(); + assert_eq!(r["name"], "New Gradient Swatch"); + let d = &s.doc().unwrap().doc; + let w = d.swatch("New Gradient Swatch").unwrap(); + assert!(w.named && !w.hidden); + let SwatchValue::Gradient { gradient } = &w.value else { panic!("{w:?}") }; + assert_eq!(gradient.stops[0].color, d.resolve_color("C=0 M=100 Y=0 K=0", 1.0).unwrap(), "sorted: magenta first"); + assert_eq!(gradient.stops[1].color, d.resolve_color("C=100 M=0 Y=0 K=0", 1.0).unwrap()); + // A second one gets its own name; bad stops are refused. + let r = s + .execute( + "swatch.create", + &json!({"gradient": {"kind": "radial", "stops": [{"color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}}), + ) + .unwrap(); + assert_eq!(r["name"], "New Gradient Swatch 2"); + for bad in [ + json!({"stops": [{"swatch": "C=0 M=100 Y=0 K=0"}]}), + json!({"stops": [{"swatch": "Nope"}, {"swatch": "[Black]"}]}), + json!({"stops": [{"swatch": "New Gradient Swatch"}, {"swatch": "[Black]"}]}), + json!({"kind": "conic", "stops": [{"color": "#000000"}, {"color": "#ffffff"}]}), + ] { + assert!(s.execute("swatch.create", &json!({"gradient": bad})).is_err(), "{bad}"); + } + } + + #[test] + fn gradient_stops_follow_their_swatch() { + let mut s = doc(); + s.execute("swatch.create", &json!({"name": "Gold", "color": {"c": 0, "m": 30, "y": 90, "k": 10}, "spot": true})).unwrap(); + s.execute( + "swatch.create", + &json!({"name": "G", "gradient": {"stops": [{"location": 0, "swatch": "Gold"}, {"location": 100, "swatch": "[Paper]"}]}}), + ) + .unwrap(); + let stops = |s: &crate::Session| match &s.doc().unwrap().doc.swatch("G").unwrap().value { + SwatchValue::Gradient { gradient } => gradient.stops.clone(), + v => panic!("{v:?}"), + }; + assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold")); + assert_eq!(stops(&s)[1].swatch.as_deref(), Some("[Paper]")); + // Renamed and edited: the stop names the new name and shows the new color. + s.execute("swatch.options", &json!({"name": "Gold", "to": "Gold 871", "color": {"c": 0, "m": 20, "y": 80, "k": 20}})).unwrap(); + assert_eq!(stops(&s)[0].swatch.as_deref(), Some("Gold 871")); + assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2)); + // Deleted: the stop keeps its color as a plain one. + s.execute("swatch.delete", &json!({"name": "Gold 871"})).unwrap(); + assert_eq!(stops(&s)[0].swatch, None); + assert_eq!(stops(&s)[0].color, designcraft_color::Color::cmyk(0.0, 0.2, 0.8, 0.2)); + } + + #[test] + fn new_swatch_names_the_selections_gradient_or_duplicates_a_swatch() { + let mut s = doc(); + let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap(); + // A gradient made in the Gradient panel is unnamed (and not listed) … + s.execute("object.gradient", &json!({"ids": [id], "stops": [{"location": 0, "color": {"c": 0, "m": 100, "y": 0, "k": 0}}, {"location": 100, "color": {"c": 100, "m": 0, "y": 0, "k": 0}}]})).unwrap(); + let fill = |s: &crate::Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().fill.swatch.clone(); + assert!(s.doc().unwrap().doc.swatch(&fill(&s)).unwrap().hidden); + // … until New Swatch: "New Gradient Swatch", listed, still the object's fill. + s.execute("selection.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch"); + assert_eq!(fill(&s), "New Gradient Swatch"); + let w = s.doc().unwrap().doc.swatch("New Gradient Swatch").unwrap().clone(); + assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Gradient { .. })); + // A named swatch: a copy. + assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "New Gradient Swatch copy"); + assert_eq!(s.execute("swatch.new", &json!({"swatch": "C=100 M=0 Y=0 K=0"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy"); + assert!(s.execute("swatch.new", &json!({"swatch": "[None]"})).is_err()); + // The stroke in front: the stroke's swatch. + s.execute("object.stroke", &json!({"ids": [id], "swatch": "C=100 M=0 Y=0 K=0"})).unwrap(); + assert_eq!(s.execute("swatch.new", &json!({"target": "stroke"})).unwrap()["name"], "C=100 M=0 Y=0 K=0 copy 2"); + // Nothing selected, no swatch given: a new colour swatch. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let n = s.execute("swatch.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); + let w = s.doc().unwrap().doc.swatch(&n).unwrap(); + assert!(w.named && !w.hidden && matches!(w.value, SwatchValue::Color { .. }), "{w:?}"); + } +} + +#[cfg(test)] +mod gradient_stroke_tests { + use serde_json::json; + + /// Grey along a 20 pt line from (20, 60) to (180, 60): near its start, middle and end. + fn along(img: &designcraft_render::Rendered) -> (u8, u8, u8) { + (img.pixel(26, 60)[0], img.pixel(100, 60)[0], img.pixel(174, 60)[0]) + } + + #[test] + fn a_gradient_on_a_stroke_runs_across_the_path_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let r = s + .execute( + "object.gradient", + &json!({"ids": [id], "target": "stroke", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(designcraft_doc::ItemId(id)).unwrap(); + assert_eq!(it.stroke.swatch, r["swatch"].as_str().unwrap()); + assert_eq!(it.fill.swatch, designcraft_color::swatch::NONE, "the fill is left alone"); + let mut rr = designcraft_render::Renderer::new(); + let (a, m, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "screen: {a} {m} {b}"); + // The PDF, placed back and drawn: the same. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "stroke.pdf", "x": 0, "y": 0, "width": 200})) + .unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let (a, m, b) = along(&rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a < 40 && (90..170).contains(&m) && b > 215, "pdf: {a} {m} {b}"); + // The Gradient Swatch tool's drag on the stroke: reversed by dragging right to left. + s.execute("object.gradient", &json!({"ids": [id], "target": "stroke", "from": [180, 60], "to": [20, 60]})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let (a, _, b) = along(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap()); + assert!(a > 215 && b < 40, "dragged: {a} {b}"); + assert!(s.execute("object.gradient", &json!({"ids": [id], "target": "gap"})).is_err()); + } + + /// A 20 pt horizontal line with a gradient on its stroke, drawn on screen and from its PDF. + fn line_with_stroke_gradient(g: serde_json::Value) -> (designcraft_render::Rendered, designcraft_render::Rendered) { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + let id = s.execute("line.create", &json!({"a": [20, 60], "b": [180, 60]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.stroke", &json!({"ids": [id], "weight": 20})).unwrap(); + let mut g = g; + g["ids"] = json!([id]); + g["target"] = json!("stroke"); + s.execute("object.gradient", &g).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + let screen = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 200, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "line.pdf", "x": 0, "y": 0, "width": 200})).unwrap(); + let pd = p.doc().unwrap().doc.clone(); + let placed = rr.render_page(&pd, &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + (screen, placed) + } + + #[test] + fn a_radial_gradient_on_a_straight_lines_stroke_shows_all_its_stops() { + // The line's box has no height: the gradient is a circle across the line's length. + let (screen, pdf) = line_with_stroke_gradient( + json!({"kind": "radial", "stops": [{"location": 0, "color": "#000000"}, {"location": 100, "color": "#ffffff"}]}), + ); + for (what, img) in [("screen", &screen), ("pdf", &pdf)] { + let (a, m, b) = along(img); + assert!(m < 40 && a > 180 && b > 180, "{what}: {a} {m} {b}"); + } + } + + #[test] + fn a_linear_gradient_across_a_straight_line_paints_its_stroke_alike_on_screen_and_in_pdf() { + let (screen, pdf) = + line_with_stroke_gradient(json!({"angle": 90, "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})); + let (s, p) = (screen.pixel(100, 60), pdf.pixel(100, 60)); + // The line's box has no height: the gradient runs across the line's length, so the stroke + // shows its middle. + assert!(s[1] < 60 && s[0] > 80 && s[2] > 80, "screen: the stroke is painted, {s:?}"); + assert!((0..3).all(|i| s[i].abs_diff(p[i]) < 40), "screen {s:?}, pdf {p:?}"); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 776dd5e82..2a124e7a9 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -17,2466 +17,15 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, f64_or, has_doc, has_text, ok, str_param}; use crate::{Result, Session}; -pub fn specs() -> Vec { - let mut v = style_specs(); - v.extend(table_specs()); - v -} - -fn style_specs() -> Vec { - vec![ - cmd!( - "style.cell.create", - "New Cell Style…", - [], - None, - "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", - has_doc, - |s, p| cell_style_create(s, p) - ), - cmd!( - "style.cell.apply", - "Apply Cell Style", - [], - None, - "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — the target cells (the whole table when the cursor is in it)", - in_table, - cell_style_apply - ), - cmd!( - "style.cell.edit", - "Cell Style Options…", - [], - None, - "{name, …same as style.cell.create} — cells using the style follow", - has_doc, - cell_style_edit - ), - cmd!( - "style.table.create", - "New Table Style…", - [], - None, - "{name, basedOn?: table style name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", - has_doc, - table_style_create - ), - cmd!( - "style.table.apply", - "Apply Table Style", - [], - None, - "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?}", - in_table, - table_style_apply - ), - cmd!( - "style.table.edit", - "Table Style Options…", - [], - None, - "{name, …same as style.table.create} — tables using the style follow", - has_doc, - table_style_edit - ), - ] -} - -fn table_specs() -> Vec { - vec![ - cmd!( - "table.insert", - "Create Table", - [], - Some("Cmd+Alt+Shift+T"), - "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", - has_text, - insert - ), - cmd!( - "table.convertFromText", - "Convert Text to Table", - ["Table"], - None, - "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", - has_text, - convert_from_text - ), - cmd!( - "table.convertToText", - "Convert Table to Text", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", - in_table, - convert_to_text - ), - cmd!( - "table.insertRow", - "Insert Row", - [], - None, - "{where?: above|below, count? (1), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_row(s, p, None) - ), - cmd!( - "table.insertRowAbove", - "Insert Row Above", - ["Table", "Insert"], - None, - "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_row(s, p, Some(true)) - ), - cmd!( - "table.insertRowBelow", - "Insert Row Below", - ["Table", "Insert"], - None, - "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_row(s, p, Some(false)) - ), - cmd!( - "table.insertColumn", - "Insert Column", - [], - None, - "{where?: left|right, count? (1), width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_col(s, p, None) - ), - cmd!( - "table.insertColumnLeft", - "Insert Column Left", - ["Table", "Insert"], - None, - "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_col(s, p, Some(true)) - ), - cmd!( - "table.insertColumnRight", - "Insert Column Right", - ["Table", "Insert"], - None, - "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - |s, p| insert_col(s, p, Some(false)) - ), - cmd!( - "table.deleteRow", - "Delete Row", - ["Table", "Delete"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the rows of the target cells", - in_table, - delete_rows - ), - cmd!( - "table.deleteColumn", - "Delete Column", - ["Table", "Delete"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the columns of the target cells", - in_table, - delete_cols - ), - cmd!( - "table.delete", - "Delete Table", - ["Table", "Delete"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - delete_table - ), - cmd!( - "table.merge", - "Merge Cells", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — merge the target cell range", - in_table, - merge - ), - cmd!( - "table.dropCells", - "Drag Rows or Columns", - [], - None, - "{frame, from: [x, y], to: [x, y]} — selected whole rows (or columns) dragged from `from` move to the row (column) at `to`; otherwise a text drag", - has_doc, - drop_cells - ), - cmd!( - "table.placeGraphic", - "Convert Cell to Graphic Cell", - ["Table", "Convert Cell Type"], - None, - "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", - in_table, - place_graphic - ), - cmd!( - "table.textCell", - "Convert Cell to Text Cell", - ["Table", "Convert Cell Type"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", - in_table, - |s, p| { - let g = target(s, p, "table.textCell")?; - edit_table(s, &g, "table.textCell", |t| { - for (r, c) in t.selected_owners(g.range) { - if let Some(cell) = t.cell_mut(r, c) { - cell.graphic = None; - } - } - ok() - }) - } - ), - cmd!( - "table.moveRow", - "Move Row", - [], - None, - "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target row) — like dragging a row", - in_table, - |s, p| move_rc(s, p, true) - ), - cmd!( - "table.moveColumn", - "Move Column", - [], - None, - "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target column)", - in_table, - |s, p| move_rc(s, p, false) - ), - cmd!( - "table.sortRows", - "Sort", - ["Table"], - None, - "{column?: 0-based (default: the target cell's), descending?: bool, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — body rows by that column's text (numbers numerically)", - in_table, - sort_rows - ), - cmd!( - "table.unmerge", - "Unmerge Cells", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - unmerge - ), - cmd!( - "table.splitHorizontally", - "Split Cell Horizontally", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, one above the other", - in_table, - |s, p| split(s, p, true) - ), - cmd!( - "table.splitVertically", - "Split Cell Vertically", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, side by side", - in_table, - |s, p| split(s, p, false) - ), - cmd!( - "table.setCell", - "Cell Options", - [], - None, - "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", - in_table, - set_cell - ), - cmd!( - query "table.getCellStroke", - "Get Cell Stroke", - [], - None, - "{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null", - in_table_or_ids, - get_cell_stroke - ), - cmd!( - "table.setRowHeight", - "Row Height", - [], - None, - "{height, mode?: atLeast|exactly, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - set_row_height - ), - cmd!( - "table.setColumnWidth", - "Column Width", - [], - None, - "{width, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - set_col_width - ), - cmd!( - "table.resizeBorder", - "Resize Row or Column", - [], - None, - "{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`", - in_table_or_ids, - resize_border - ), - cmd!( - "table.distributeColumns", - "Distribute Columns Evenly", - ["Table"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - distribute_cols - ), - cmd!( - "table.distributeRows", - "Distribute Rows Evenly", - ["Table"], - None, - "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", - in_table, - distribute_rows - ), - cmd!( - "table.options", - "Table Options", - [], - None, - "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", - in_table, - options - ), - cmd!( - "table.convertRowsToHeader", - "To Header", - ["Table", "Convert Rows"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the top of the table or right after its header rows", - |s| can_convert_rows(s, RowKind::Header), - |s, p| convert_rows(s, p, RowKind::Header) - ), - cmd!( - "table.convertRowsToBody", - "To Body", - ["Table", "Convert Rows"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target header rows next to the body, footer rows next to it, or both", - |s| can_convert_rows(s, RowKind::Body), - |s, p| convert_rows(s, p, RowKind::Body) - ), - cmd!( - "table.convertRowsToFooter", - "To Footer", - ["Table", "Convert Rows"], - None, - "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the bottom of the table or right before its footer rows", - |s| can_convert_rows(s, RowKind::Footer), - |s, p| convert_rows(s, p, RowKind::Footer) - ), - cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select), - cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { - select(s, &super::with_param(p, "what", json!("table"))) - }), - cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { - select(s, &super::with_param(p, "what", json!("row"))) - }), - cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { - select(s, &super::with_param(p, "what", json!("column"))) - }), - cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), - cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), - cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), - ] -} - -// ---------- enablement and targeting ---------- - -fn in_table(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - let st = s.active().ok_or("no document open")?; - if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { - return Ok(()); - } - if let Some(t) = st.selection.text - && st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some()) - { - return Ok(()); - } - Err("place the insertion point in a table".into()) -} - -fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { - has_doc(s) -} - -fn in_cell(s: &Session) -> std::result::Result<(), String> { - has_doc(s)?; - if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } -} - -/// The table a command acts on and its target cell range. -struct Tgt { - story: StoryId, - table: u64, - range: CellRange, -} - -fn find_table(doc: &Document, id: u64) -> Option { - doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k) -} - -fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> { - if let Some(a) = p.get(many).and_then(Value::as_array) { - let a0 = a.first()?.as_u64()? as usize; - let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize); - return Some((a0.min(a1), a0.max(a1))); - } - p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize)) -} - -fn target(s: &Session, p: &Value, cmd: &str) -> Result { - let st = s.doc()?; - let doc = &st.doc; - let explicit_table = p.get("table").and_then(Value::as_u64); - let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId); - let (story, table, range) = if let Some(tid) = explicit_table { - let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?; - (sid, tid, None) - } else if let Some(c) = st.selection.cells { - (c.story, c.table, Some(c.range)) - } else if let Some(t) = st.selection.text { - match t.cell { - Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))), - None => { - let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?; - (t.story, tid, None) - } - } - } else { - return Err(bad(cmd, "place the insertion point in a table")); - }; - let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?; - let (nr, nc) = (t.nrows(), t.ncols()); - let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }); - if let Some((a, b)) = pair(p, "rows", "row") { - range.r0 = a; - range.r1 = b; - } - if let Some((a, b)) = pair(p, "cols", "col") { - range.c0 = a; - range.c1 = b; - } - if range.r1 >= nr || range.c1 >= nc { - return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)"))); - } - Ok(Tgt { story, table, range }) -} - -/// Edit the target table; afterwards selections pointing into it are clamped to its new shape. -fn edit_table(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result) -> Result { - let cmd = cmd.to_string(); - s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?; - let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?; - let r = f(t)?; - clamp_selection(d, sel); - Ok(r) - }) -} - -/// Keep text/cell selections valid after the table changed shape (or disappeared). -fn clamp_selection(d: &Document, sel: &mut Selection) { - if let Some(ts) = sel.cells { - match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { - Some(t) => { - let (mr, mc) = (t.nrows() - 1, t.ncols() - 1); - let r = ts.range; - sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts }); - } - None => sel.cells = None, - } - } - if let Some(t) = sel.text - && let Some(c) = t.cell - { - let story = d.story(t.story); - match story.and_then(|x| x.tables.get(&c.table)) { - Some(tb) => { - let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); - let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); - sel.text = Some(TextSel { - anchor: floor_char_boundary(text, t.anchor), - focus: floor_char_boundary(text, t.focus), - cell: Some(CellAddr { table: c.table, row: r, col }), - ..t - }); - } - None => { - let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); - sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); - } - } - } -} - -fn count(p: &Value) -> usize { - p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize -} - -fn color_param(p: &Value, key: &str) -> Option { - str_param(p, key).map(str::to_string) -} - -// ---------- create / convert ---------- - -/// Width of the text column at the caret (the new table spans it). -fn column_width(s: &Session, t: &TextSel) -> f64 { - let Some(st) = s.active() else { return 300.0 }; - let specs = compose::frame_specs(&st.doc, t.story); - let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first()); - spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0) -} - -/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style -/// chain, then local overrides). A table created there takes it. -fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection { - d.story(story) - .and_then(|st| st.paras.get(st.para_at(offset))) - .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) -} - -fn insert(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; - if t.cell.is_some() { - return Err(bad("table.insert", "tables can't be nested in cells")); - } - let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; - let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; - let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; - let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); - s.edit(|d, sel| { - let id = d.alloc(); - let mut table = Table::new(id, rows, cols, header, footer, width); - table.options.direction = para_direction(d, t.story, t.range().start); - let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?; - // Cells start with the paragraph format at the caret. - let pf = st.paras[st.para_at(t.range().start)].clone(); - let cf = st.char_format_at(t.range().start).clone(); - for c in &mut table.cells { - c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; - c.text.chars[0].format = cf.clone(); - } - let r = t.range(); - st.delete(r.clone()); - st.insert_table(r.start, table); - *sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) }); - Ok(json!({"story": t.story.0, "table": id})) - }) -} - -fn convert_from_text(s: &mut Session, p: &Value) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; - if t.cell.is_some() { - return Err(bad("table.convertFromText", "select text outside tables")); - } - let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; - let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); - s.edit(|d, sel| { - let id = d.alloc(); - let direction = para_direction(d, t.story, t.range().start); - let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?; - let ranges = st.para_ranges(); - let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end)); - if (pa..=pb).any(|i| st.paras[i].table.is_some()) { - return Err(bad("table.convertFromText", "the selection contains a table")); - } - let (a, b) = (ranges[pa].start, ranges[pb].end); - let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); - let pf = st.paras[pa].clone(); - let cf = st.format_after(a).clone(); - let mut table = Table::from_strings(id, &data, width, &pf, &cf); - table.options.direction = direction; - st.delete(a..b); - let anchor = st.insert_table(a, table); - *sel = Selection::text(TextSel::caret(t.story, anchor)); - Ok(json!({"table": id, "rows": data.len()})) - }) -} - -fn convert_to_text(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.convertToText")?; - s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; - let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); - let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; - st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); - *sel = Selection::text(TextSel::caret(g.story, a + text.len())); - ok() - }) -} - -fn delete_table(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.delete")?; - s.edit(|d, sel| { - let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?; - let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?; - // Remove the anchor paragraph (with one separator) so no empty paragraph is left behind. - let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8(); - let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') { - // Inserted at the end of the story: remove the empty paragraph after it too. - a - 1..end + 1 - } else if st.text[end..].starts_with('\n') { - a..end + 1 - } else if a > 0 && st.text[..a].ends_with('\n') { - a - 1..end - } else { - a..end - }; - st.delete(r.clone()); - *sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len()))); - ok() - }) -} - -// ---------- rows and columns ---------- - -fn insert_row(s: &mut Session, p: &Value, above: Option) -> Result { - let g = target(s, p, "table.insertRow")?; - let above = above.unwrap_or(str_param(p, "where") == Some("above")); - let n = count(p); - let at = if above { g.range.r0 } else { g.range.r1 + 1 }; - let r = edit_table(s, &g, "table.insertRow", |t| { - t.insert_rows(at, n); - Ok(json!({"rows": t.nrows()})) - })?; - shift_selection(s, g.table, at, n, true); - Ok(r) -} - -/// After inserting rows/columns at `at`, selections at or after it follow their cells. -fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) { - let Ok(st) = s.doc_mut() else { return }; - if let Some(t) = st.selection.text.as_mut() - && let Some(c) = t.cell.as_mut() - && c.table == table - { - let v = if rows { &mut c.row } else { &mut c.col }; - if *v >= at { - *v += n; - } - } - if let Some(ts) = st.selection.cells.as_mut() - && ts.table == table - { - let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) }; - if *a >= at { - *a += n; - } - if *b >= at { - *b += n; - } - } -} - -fn insert_col(s: &mut Session, p: &Value, left: Option) -> Result { - let g = target(s, p, "table.insertColumn")?; - let left = left.unwrap_or(str_param(p, "where") == Some("left")); - let rtl = s - .doc()? - .doc - .story(g.story) - .and_then(|st| st.tables.get(&g.table)) - .is_some_and(|t| t.options.direction == designcraft_doc::TextDirection::RightToLeft); - let n = count(p); - let at = if left != rtl { g.range.c0 } else { g.range.c1 + 1 }; - let width = p.get("width").and_then(Value::as_f64); - let r = edit_table(s, &g, "table.insertColumn", |t| { - let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width); - t.insert_cols(at, n, w); - Ok(json!({"columns": t.ncols()})) - })?; - shift_selection(s, g.table, at, n, false); - Ok(r) -} - -fn delete_rows(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.deleteRow")?; - let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows()); - if all { - return delete_table(s, p); - } - edit_table(s, &g, "table.deleteRow", |t| { - t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1); - Ok(json!({"rows": t.nrows()})) - }) -} - -fn delete_cols(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.deleteColumn")?; - let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols()); - if all { - return delete_table(s, p); - } - edit_table(s, &g, "table.deleteColumn", |t| { - t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1); - Ok(json!({"columns": t.ncols()})) - }) -} - -fn merge(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.merge")?; - let r = edit_table(s, &g, "table.merge", |t| { - t.merge(g.range).map_err(|e| bad("table.merge", e))?; - Ok(t.expand_range(g.range)) - })?; - // The caret goes to the merged cell. - let st = s.doc_mut()?; - st.selection = Selection::text(TextSel { - story: g.story, - anchor: 0, - focus: 0, - frame: st.selection.text.and_then(|t| t.frame), - cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }), - }); - ok() -} - -fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { - let g = target(s, p, "table.split")?; - edit_table(s, &g, "table.split", |t| { - t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; - Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) - }) -} - -fn unmerge(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.unmerge")?; - edit_table(s, &g, "table.unmerge", |t| { - for r in g.range.r0..=g.range.r1 { - for c in g.range.c0..=g.range.c1 { - t.unmerge(r, c); - } - } - ok() - }) -} - -/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and -/// right) and the lines between its rows and columns. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct EdgeSel { - top: bool, - left: bool, - bottom: bool, - right: bool, - inner_h: bool, - inner_v: bool, -} - -impl EdgeSel { - const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true }; - - fn parse(v: Option<&Value>, cmd: &str) -> Result { - let names: Vec<&Value> = match v { - None | Some(Value::Null) => return Ok(EdgeSel::ALL), - Some(Value::Array(a)) => a.iter().collect(), - Some(v) => vec![v], - }; - let mut e = EdgeSel::default(); - for n in names { - match n.as_str() { - Some("all") => e = EdgeSel::ALL, - Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true), - Some("inner") => (e.inner_h, e.inner_v) = (true, true), - Some("top") => e.top = true, - Some("left") => e.left = true, - Some("bottom") => e.bottom = true, - Some("right") => e.right = true, - Some("innerHorizontal") => e.inner_h = true, - Some("innerVertical") => e.inner_v = true, - _ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))), - } - } - if e == EdgeSel::default() { - return Err(bad(cmd, "no edges chosen")); - } - Ok(e) - } - - /// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen - /// within `rg`. Left and right are physical sides, also in RTL tables. - fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] { - let last_row = r.saturating_add((spans.0.max(1) - 1) as usize); - let last_col = c.saturating_add((spans.1.max(1) - 1) as usize); - let (start, end) = (c <= rg.c0, last_col >= rg.c1); - let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) }; - [ - if r <= rg.r0 { self.top } else { self.inner_h }, - if left_outer { self.left } else { self.inner_v }, - if last_row >= rg.r1 { self.bottom } else { self.inner_h }, - if right_outer { self.right } else { self.inner_v }, - ] - } -} - -/// A finite number in `lo..=hi`, or an error naming `key`. -fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result> { - match v.get(key) { - None | Some(Value::Null) => Ok(None), - Some(x) => match x.as_f64() { - Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)), - _ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))), - }, - } -} - -/// A swatch of the document (or [None]), or an error naming `key`. -fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result> { - let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) }; - let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?; - if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() { - return Err(bad(cmd, format!("no swatch `{name}`"))); - } - Ok(Some(name.to_string())) -} - -/// The cell stroke type names of `type`. -const CELL_STROKE_TYPES: &[&str] = - &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; - -/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the -/// resulting weight of each edge. -struct StrokeEdit { - attrs: designcraft_doc::CellStrokeAttrs, - scaled_dashes: bool, -} - -impl StrokeEdit { - fn parse(doc: &Document, v: &Value, cmd: &str) -> Result { - if !v.is_object() { - return Err(bad(cmd, "stroke must be an object")); - } - let mut attrs = designcraft_doc::CellStrokeAttrs { - weight: num_in(v, "weight", 0.0, 1000.0, cmd)?, - color: swatch_in(doc, v, &["color"], cmd)?, - tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32), - gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?, - gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32), - kind: None, - }; - let mut scaled_dashes = false; - match v.get("type") { - None | Some(Value::Null) => {} - Some(Value::String(name)) if name == "dashed" => { - scaled_dashes = true; - attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() }); - } - Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => { - attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?); - } - Some(t @ Value::Object(_)) => { - let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?; - let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0); - match &kind { - StrokeType::Dashed { pattern } if !StrokeType::valid_dash_pattern(pattern) => { - return Err(bad(cmd, "a dash pattern needs 1 to 64 lengths, each 0 or 0.1 to 10000 pt, not all 0")); - } - StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => { - return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers")); - } - StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => { - return Err(bad(cmd, format!("no stroke style `{name}`"))); - } - _ => {} - } - attrs.kind = Some(kind); - } - Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))), - } - Ok(StrokeEdit { attrs, scaled_dashes }) - } - - fn apply(&self, mut base: CellStroke) -> CellStroke { - self.attrs.apply_to(&mut base); - if self.scaled_dashes { - let w = base.weight.max(1.0); - base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] }; - } - base - } -} - -/// The stroke drawn on side `side` of the owner cell at `(r, c)` (where that side meets its -/// first neighbour), as rendering resolves it; the cell's own copy if the side has no stretch. -fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side: usize) -> Option { - let drawn = compose::table::cell_side_strokes(t, owners, (r, c), side).first().map(|s| s.stroke.clone()); - drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned())) -} - -/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt). -fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> { - let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) { - Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?), - None => None, - }; - let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) { - Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) { - Some(m) => Some(m), - None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")), - }, - None => None, - }; - Ok((fb, min)) -} - -fn set_cell(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.setCell"; - let g = target(s, p, ID)?; - let doc = &s.doc()?.doc; - let fill = swatch_in(doc, p, &["fill"], ID)?; - let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32); - let insets: Option<[f64; 4]> = match p.get("insets") { - Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]), - Some(Value::Array(a)) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); - Some([v[0], v[1], v[2], v[3]]) - } - _ => None, - }; - let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) { - Some("top") => Some(VerticalJustification::Top), - Some("center") => Some(VerticalJustification::Center), - Some("bottom") => Some(VerticalJustification::Bottom), - Some("justify") => Some(VerticalJustification::Justify), - Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), - None => None, - }; - let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; - // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is - // composed so far, which the IDML import reports (#1468). - let rotation = match p.get("rotation").filter(|v| !v.is_null()) { - Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) { - Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d), - _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")), - }, - None => None, - }; - let text = str_param(p, "text").map(|_| super::text_param(p, "text")); - let stroke = match p.get("stroke").filter(|v| !v.is_null()) { - Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), - None => None, - }; - edit_table(s, &g, ID, |t| { - let owners = t.owners(); - let nc = t.ncols(); - let nr = t.nrows(); - let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; - // Local edits outrank previously imported edges, including explicit zero/None. - let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; - let rg = g.range; - // Edges are inner or outer within the selection grown to whole merged cells, so a - // merged cell selected through one of its grid positions keeps its own outline. - let outline = t.expand_range(rg); - // Each chosen edge starts from the stroke drawn there, which both copies of a shared - // edge then take, so the edit changes only the attributes it names. - let mut bases = std::collections::HashMap::new(); - if let Some((_, edges)) = &stroke { - for (r, c) in t.selected_owners(rg) { - let Some(cell) = t.cell(r, c) else { continue }; - for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { - if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) { - bases.insert((r, c, i), base); - } - } - } - } - let mut edited = std::collections::HashSet::new(); - for (r, c) in t.selected_owners(rg) { - let Some(cell) = t.cell_mut(r, c) else { continue }; - if let Some(f) = &fill { - cell.fill = f.clone(); - } - if let Some(ti) = tint { - cell.fill_tint = ti; - } - if let Some(i) = insets { - cell.insets = i; - } - if let Some(v) = vj { - cell.vj = v; - } - if let Some(v) = first_baseline { - cell.first_baseline = v; - } - if let Some(v) = first_baseline_min { - cell.first_baseline_min = v; - } - if let Some(v) = rotation { - cell.rotation = v; - } - if let Some(tx) = &text { - let len = cell.text.len(); - cell.text.replace(0..len, tx); - } - if let Some((edit, _)) = &stroke { - for i in 0..4 { - if let Some(base) = bases.remove(&(r, c, i)) { - edited.insert((r, c, i)); - cell.strokes[i] = edit.apply(base); - cell.stroke_defined[i] = true; - cell.stroke_priorities[i] = priority; - cell.border_overrides[i] = true; - } - } - } - } - // Keep both copies of unmerged shared boundaries consistent: each edited edge is - // copied to its neighbour. Left/right name physical sides, also in RTL tables. - let mirror = |t: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| { - if edited.contains(&(from.0, from.1, edge)) { - mirror_cell_edge(t, &owners, from, edge, to, opposite); - } - }; - if let Some((_, edges)) = &stroke { - if edges.top - && let Some(row) = rg.r0.checked_sub(1) - { - for c in rg.c0..=rg.c1 { - mirror(t, (rg.r0, c), 0, (row, c), 2); - } - } - if edges.bottom && rg.r1.saturating_add(1) < nr { - for c in rg.c0..=rg.c1 { - mirror(t, (rg.r1, c), 2, (rg.r1 + 1, c), 0); - } - } - if (if rtl { edges.right } else { edges.left }) - && let Some(col) = rg.c0.checked_sub(1) - { - for r in rg.r0..=rg.r1 { - mirror(t, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); - } - } - if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc { - for r in rg.r0..=rg.r1 { - mirror(t, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); - } - } - } - ok() - }) -} - -/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values -/// that differ are null. -fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.getCellStroke"; - let g = target(s, p, ID)?; - let edges = EdgeSel::parse(p.get("edges"), ID)?; - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - let owners = t.owners(); - let outline = t.expand_range(g.range); - let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; - // One value per key, or null once two edges (cells) disagree. - fn merge(slot: &mut Option, v: Value) { - match slot { - None => *slot = Some(v), - Some(cur) if *cur != v => *cur = Value::Null, - _ => {} - } - } - let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"]; - let mut stroke: [Option; 6] = Default::default(); - let (mut fill, mut fill_tint) = (None, None); - // Each drawn edge once: a shared edge is a side of both its cells. - let mut seen = std::collections::HashSet::new(); - for (r, c) in t.selected_owners(g.range) { - let Some(cell) = t.cell(r, c) else { continue }; - merge(&mut fill, json!(cell.fill)); - merge(&mut fill_tint, json!(cell.fill_tint)); - for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { - if !on { - continue; - } - for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) { - let (x, y) = (Some((r, c)), side.neighbor); - if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) { - continue; - } - let e = side.stroke; - let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; - for (slot, v) in stroke.iter_mut().zip(values) { - merge(slot, v); - } - } - } - } - let mut out = serde_json::Map::new(); - for (k, v) in keys.iter().zip(stroke) { - out.insert((*k).into(), v.unwrap_or(Value::Null)); - } - out.insert("fill".into(), fill.unwrap_or(Value::Null)); - out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null)); - out.insert("edges".into(), json!(seen.len())); - Ok(Value::Object(out)) -} - -/// Only saturation needs rebasing. Preserve every distinct priority's order and keep -/// zero/negative priorities untouched; the work is bounded by the table's edges. -fn next_cell_stroke_priority(t: &mut Table) -> i32 { - let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0); - if highest < i32::MAX { - return highest + 1; - } - let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect(); - let mut ranks = std::collections::BTreeMap::new(); - let mut highest = 0i32; - for value in values { - highest = highest.saturating_add(1); - ranks.insert(value, highest); - } - for cell in &mut t.cells { - for priority in &mut cell.stroke_priorities { - if let Some(rank) = ranks.get(priority) { - *priority = *rank; - } - } - } - highest.saturating_add(1) -} - -fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) { - let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied(); - if owner(from) != Some(from) || owner(to) != Some(to) { - return; - } - // A merged cell's edge may border several separately editable cells. Mirroring - // a segment onto that whole edge would change unselected neighbors; priorities - // instead resolve each segment without altering the merged cell's formatting. - let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1; - if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) { - return; - } - let source = t - .cell(from.0, from.1) - .map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge])); - if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) { - neighbor.strokes[opposite] = stroke; - neighbor.stroke_defined[opposite] = defined; - neighbor.stroke_priorities[opposite] = priority; - neighbor.border_overrides[opposite] = border_override; - } -} - -fn set_row_height(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.setRowHeight")?; - let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0)); - let mode = match str_param(p, "mode") { - Some("exactly") => Some(RowHeightMode::Exactly), - Some("atLeast") => Some(RowHeightMode::AtLeast), - Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))), - None => None, - }; - if h.is_none() && mode.is_none() { - return Err(bad("table.setRowHeight", "missing height")); - } - edit_table(s, &g, "table.setRowHeight", |t| { - for row in &mut t.rows[g.range.r0..=g.range.r1] { - if let Some(h) = h { - row.height = h; - } - if let Some(m) = mode { - row.mode = m; - } - } - ok() - }) -} - -fn set_col_width(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.setColumnWidth")?; - let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0); - edit_table(s, &g, "table.setColumnWidth", |t| { - for c in &mut t.columns[g.range.c0..=g.range.c1] { - c.width = w; - } - ok() - }) -} - -/// Smallest column width and row height a border drag leaves. -const MIN_TRACK: f64 = 3.0; -const MAX_TRACK: f64 = 10000.0; - -/// Composed height of `row` (laid out from cells that span only that row), else its model height. -fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option { - let laid_out = cs - .frames - .iter() - .flat_map(|f| &f.tables) - .filter(|f| f.table == t.id) - .flat_map(|f| &f.cells) - .filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1)) - .map(|c| c.rect.height()) - .filter(|h| h.is_finite() && *h > 0.0) - .reduce(f64::max); - laid_out.or_else(|| t.rows.get(row).map(|r| r.height)) -} - -/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes. -fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option> { - let mut out = sizes.to_vec(); - let cur = *sizes.get(index)?; - let last = index + 1 == sizes.len(); - if keep && last { - // Scale every track so the table edge moves by `delta`. - let total: f64 = sizes.iter().sum(); - let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min); - let largest = sizes.iter().copied().fold(0.0, f64::max); - if !(total > 0.0 && smallest > 0.0 && largest > 0.0) { - return None; - } - let lo = (MIN_TRACK / smallest).min(1.0); - let hi = (MAX_TRACK / largest).max(1.0); - let f = ((total + delta) / total).clamp(lo, hi); - out.iter_mut().for_each(|v| *v *= f); - } else if keep { - let next = *sizes.get(index + 1)?; - let pair = cur + next; - let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK)); - let [this, after] = out.get_mut(index..index + 2)? else { return None }; - *this = a; - *after = (pair - a).max(MIN_TRACK); - } else { - *out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK); - } - out.iter().all(|v| v.is_finite()).then_some(out) -} - -/// The frame a border drag names must hold the table's story and be editable. -fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> { - const ID: &str = "table.resizeBorder"; - if let Some(fid) = super::id_param(p, "frame") { - let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; - if it.text_frame().map(|f| f.story) != Some(story) { - return Err(bad(ID, "the table is not in that frame's story")); - } - if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { - return Err(bad(ID, "the frame or its layer is locked")); - } - } - Ok(()) -} - -/// The change in track size: `delta`, or `size` measured from the current size `cur`. -fn border_delta(p: &Value, cur: f64) -> Result { - const ID: &str = "table.resizeBorder"; - let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { - (Some(d), _) => d, - (None, Some(sz)) => sz - cur, - (None, None) => return Err(bad(ID, "`delta` or `size` required")), - }; - if !delta.is_finite() { - return Err(bad(ID, "`delta`/`size` must be a finite number")); - } - Ok(delta) -} - -fn resize_border(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.resizeBorder"; - let column = match str_param(p, "axis") { - Some("column") => true, - Some("row") => false, - _ => return Err(bad(ID, "axis must be `column` or `row`")), - }; - let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?; - let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false); - let g = target(s, p, ID)?; - let st = s.doc()?; - check_border_frame(st, p, g.story)?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - let sizes: Vec = if column { - t.columns.iter().map(|c| c.width).collect() - } else { - let cs = s.cache.get(&st.doc, g.story, None); - (0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect() - }; - let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?; - let delta = border_delta(p, cur)?; - let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?; - let size = new.get(index).copied().unwrap_or(cur); - let scaled = keep && index + 1 == sizes.len(); - edit_table(s, &g, ID, |t| { - if column { - for (c, w) in t.columns.iter_mut().zip(&new) { - c.width = *w; - } - } else { - for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() { - // Only the rows the drag changed take the new height. - if scaled || i == index || (keep && i == index + 1) { - r.height = *h; - } - if i == index && !scaled { - r.mode = RowHeightMode::AtLeast; - } - } - } - Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size})) - }) -} - -fn distribute_rows(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.distributeRows"; - if !p.is_object() { - return Err(bad(ID, "parameters must be an object")); - } - if let Some(rows) = p.get("rows") { - let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); - if !valid { - return Err(bad(ID, "rows must be two non-negative row indices")); - } - } - for key in ["row", "story", "table"] { - if let Some(v) = p.get(key) - && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) - { - return Err(bad(ID, format!("{key} must be a non-negative integer"))); - } - } - let g = target(s, p, ID)?; - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - if t.cells.iter().any(|c| c.row_span > 1) { - return Err(bad(ID, "unmerge cells that span rows first")); - } - let cs = s.cache.get(&st.doc, g.story, None); - let mut total = 0.0; - for row in g.range.r0..=g.range.r1 { - let height = cs - .frames - .iter() - .flat_map(|f| &f.tables) - .filter(|f| f.table == g.table) - .flat_map(|f| &f.cells) - .filter(|c| c.row == row) - .map(|c| c.rect.height()) - .reduce(f64::max) - .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; - if !height.is_finite() || height <= 0.0 { - return Err(bad(ID, "invalid composed row height")); - } - total += height; - } - let count = g.range.r1 - g.range.r0 + 1; - let height = total / count as f64; - if !height.is_finite() { - return Err(bad(ID, "invalid total row height")); - } - edit_table(s, &g, ID, |t| { - let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; - for row in rows { - row.height = height; - row.mode = RowHeightMode::Exactly; - } - Ok(json!({"rows": count, "height": height})) - }) -} - -fn distribute_cols(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.distributeColumns")?; - edit_table(s, &g, "table.distributeColumns", |t| { - let cols = &mut t.columns[g.range.c0..=g.range.c1]; - let avg = cols.iter().map(|c| c.width).sum::() / cols.len() as f64; - cols.iter_mut().for_each(|c| c.width = avg); - ok() - }) -} - -fn parse_alt(v: &Value) -> Option { - if v.is_null() { - return None; - } - let d = AltFills::default(); - Some(AltFills { - first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)), - first_color: color_param(v, "firstColor").unwrap_or(d.first_color), - first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32), - next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)), - next_color: color_param(v, "nextColor").unwrap_or(d.next_color), - next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32), - skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), - skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), - }) -} - -fn options(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.options")?; - let border = match p.get("border").filter(|v| !v.is_null()) { - Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?), - None => None, - }; - let p = p.clone(); - edit_table(s, &g, "table.options", |t| { - let o = &mut t.options; - if let Some(v) = p.get("direction") { - o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?; - } - if let Some(b) = &border { - o.border = b.apply(o.border.clone()); - o.borders = Default::default(); - } - o.space_before = f64_or(&p, "spaceBefore", o.space_before); - o.space_after = f64_or(&p, "spaceAfter", o.space_after); - if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) { - o.repeat_header = b; - } - if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) { - o.repeat_footer = b; - } - if let Some(v) = p.get("altRows") { - o.alt_rows = parse_alt(v); - } - if let Some(v) = p.get("altCols") { - o.alt_cols = parse_alt(v); - } - // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. - let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); - let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); - if h.is_some() || f.is_some() { - let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); - // Add rows when there aren't enough for the requested header/footer plus one body row. - let need = h + f + 1; - if t.nrows() < need { - let at = t.nrows(); - t.insert_rows(at, need - at); - } - t.set_header_footer(h, f); - } - Ok(json!({"options": t.options})) - }) -} - -/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind. -fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; - if st.selection.cells.is_none() { - in_cell(s)?; - } - let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?; - t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ()) -} - -fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result { - const ID: &str = "table.convertRows"; - let g = target(s, p, ID)?; - edit_table(s, &g, ID, |t| { - let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?; - t.set_header_footer(h, f); - Ok(json!({"headerRows": h, "footerRows": f})) - }) -} - -// ---------- selection ---------- - -fn select(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.select")?; - let (nr, nc) = { - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?; - (t.nrows(), t.ncols()) - }; - let mut r = g.range; - match str_param(p, "what") { - Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }, - Some("row") => { - r.c0 = 0; - r.c1 = nc - 1; - } - Some("column") => { - r.r0 = 0; - r.r1 = nr - 1; - } - _ => {} - } - let st = s.doc_mut()?; - let frame = st.selection.text.and_then(|t| t.frame); - st.selection = Selection { - cells: Some(TableSel { story: g.story, table: g.table, range: r }), - text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), - ..Default::default() - }; - st.bump_revision(); - Ok(json!({"story": g.story.0, "table": g.table, "range": r})) -} - -/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last -/// cell adds a row. -pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { - let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?; - let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?; - let (owners, nr, nc) = { - let st = s.doc()?; - let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?; - (tb.owners(), tb.nrows(), tb.ncols()) - }; - let cur = c.row * nc + c.col; - let next = - if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) }; - let (row, col) = match next { - Some(i) => (i / nc, i % nc), - None if forward => { - let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) }; - edit_table(s, &g, "table.nextCell", |tb| { - tb.insert_rows(nr, 1); - ok() - })?; - (nr, 0) - } - None => return ok(), - }; - let st = s.doc_mut()?; - let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len()); - st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t }); - st.bump_revision(); - Ok(json!({"row": row, "col": col})) -} - -fn get(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.get")?; - let st = s.doc()?; - let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?; - let owners = t.owners(); - let cells: Vec = (0..t.nrows()) - .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) - .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) - .filter_map(|(r, c)| { - let cell = t.cell(r, c)?; - Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) - }) - .collect(); - Ok(json!({ - "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), - "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), - "cells": cells, "options": t.options, "range": g.range, "style": t.style, - })) -} - -// ---------- cell and table styles ---------- - -/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default -/// edge, validated like `table.setCell`'s stroke. -fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { - match v.filter(|v| !v.is_null()) { - Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))), - None => Ok(None), - } -} - -/// Fill in the cell style fields given in `p`. -fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option, cmd: &str) -> Result<()> { - if let Some(v) = p.get("basedOn") { - cs.based_on = v.as_str().map(str::to_string); - } - if let Some(f) = color_param(p, "fill") { - cs.fill = Some(f); - } - if let Some(t) = p.get("tint").and_then(Value::as_f64) { - cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); - } - match p.get("insets") { - Some(Value::Number(n)) => { - cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]); - cs.inset_overrides = Default::default(); - } - Some(Value::Array(a)) if a.len() == 4 => { - let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); - cs.insets = Some([v[0], v[1], v[2], v[3]]); - cs.inset_overrides = Default::default(); - } - _ => {} - } - if let Some(v) = p.get("vj") { - cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); - } - let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?; - if first_baseline.is_some() { - cs.first_baseline = first_baseline; - } - if first_baseline_min.is_some() { - cs.first_baseline_min = first_baseline_min; - } - if let Some(st) = stroke { - cs.stroke = Some(st); - cs.strokes = Default::default(); - } - if let Some(ps) = str_param(p, "paragraphStyle") { - cs.paragraph_style = Some(ps.to_string()); - } - Ok(()) -} - -fn cell_style_create(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Cell Style 1").to_string(); - let mut cs = designcraft_doc::CellStyle::default(); - if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) { - let g = target(s, p, "style.cell.create")?; - let d = &s.doc()?.doc; - let cell = d - .story(g.story) - .and_then(|st| st.tables.get(&g.table)) - .and_then(|t| { - let (r, c) = t.owner(g.range.r0, g.range.c0); - t.cell(r, c) - }) - .ok_or_else(|| bad("style.cell.create", "no cell"))?; - cs.fill = Some(cell.fill.clone()); - cs.fill_tint = Some(cell.fill_tint); - cs.insets = Some(cell.insets); - cs.vj = Some(cell.vj); - cs.first_baseline = Some(cell.first_baseline); - cs.first_baseline_min = Some(cell.first_baseline_min); - cs.stroke = Some(cell.strokes[0].clone()); - cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); - } - let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?; - cell_style_fields(&mut cs, p, stroke, "style.cell.create")?; - s.edit(|d, _| { - let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base); - cs.name = name.clone(); - d.styles_mut().cell.push(cs); - Ok(json!({"name": name})) - }) -} - -fn cell_style_apply(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); - let styles = &s.doc()?.doc.styles.cell; - if !styles.iter().any(|c| c.name == name) { - return Err(bad("style.cell.apply", format!("no cell style `{name}`"))); - } - let cs = designcraft_doc::resolve_cell_style(styles, &name); - let g = target(s, p, "style.cell.apply")?; - edit_table(s, &g, "style.cell.apply", |t| { - let mut n = 0; - for (r, c) in t.selected_owners(g.range) { - if let Some(cell) = t.cell_mut(r, c) { - cs.apply_to(cell); - n += 1; - } - } - Ok(json!({"cells": n})) - }) -} - -/// Re-apply cell styles named in `names` to every cell using them (after a style edit). -fn reapply_cell_styles(d: &mut Document, names: &[String]) { - let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect(); - for sid in d.stories.keys().copied().collect::>() { - let Some(st) = d.story_mut(sid) else { continue }; - for t in st.tables.values_mut() { - if !t.cells.iter().any(|c| names.contains(&c.style)) { - continue; - } - for cell in &mut std::sync::Arc::make_mut(t).cells { - if names.contains(&cell.style) - && let Some(cs) = styles.iter().find(|x| x.name == cell.style) - { - cs.apply_to(cell); - } - } - } - st.rev = st.rev.wrapping_add(1); - } -} - -/// Include descendants when editing an inherited style, with the resolver's depth limit. -fn dependent_styles<'a>(name: &str, styles: impl Iterator)>) -> Vec { - let styles: Vec<_> = styles.collect(); - styles - .iter() - .filter(|(candidate, _)| { - let mut current = Some(*candidate); - for _ in 0..32 { - let Some(current_name) = current else { return false }; - if current_name == name { - return true; - } - current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base); - } - false - }) - .map(|(name, _)| (*name).to_string()) - .collect() -} - -fn cell_style_edit(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); - let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?; - let p = p.clone(); - s.edit(|d, _| { - let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; - cell_style_fields(cs, &p, stroke, "style.cell.edit")?; - let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); - reapply_cell_styles(d, &names); - ok() - }) -} - -fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option) { - if let Some(v) = p.get("basedOn") { - ts.based_on = v.as_str().map(str::to_string); - } - for (k, f) in [ - ("header", &mut ts.header), - ("body", &mut ts.body), - ("footer", &mut ts.footer), - ("leftColumn", &mut ts.left_column), - ("rightColumn", &mut ts.right_column), - ] { - if let Some(v) = p.get(k) { - *f = v.as_str().map(str::to_string); - } - } - if let Some(b) = border { - ts.border = Some(b); - ts.borders = Default::default(); - } - if let Some(a) = p.get("altRows").and_then(Value::as_object) { - let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32); - let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); - let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); - ts.alt_rows = Some(designcraft_doc::AltFills { - first: n("first", 1), - first_color: c("firstColor"), - first_tint: t("firstTint"), - next: n("next", 1), - next_color: c("nextColor"), - next_tint: t("nextTint"), - skip_first: n("skipFirst", 0), - skip_last: n("skipLast", 0), - }); - } - if let Some(v) = p.get("spaceBefore").and_then(Value::as_f64) { - ts.space_before = Some(v); - } - if let Some(v) = p.get("spaceAfter").and_then(Value::as_f64) { - ts.space_after = Some(v); - } -} - -fn table_style_create(s: &mut Session, p: &Value) -> Result { - let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); - let mut ts = designcraft_doc::TableStyle::default(); - let border = stroke_param(s, p.get("border"), "style.table.create")?; - table_style_fields(&mut ts, p, border); - s.edit(|d, _| { - let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base); - ts.name = name.clone(); - d.styles_mut().table.push(ts); - Ok(json!({"name": name})) - }) -} - -fn table_style_apply(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); - let doc = &s.doc()?.doc; - if !doc.styles.table.iter().any(|c| c.name == name) { - return Err(bad("style.table.apply", format!("no table style `{name}`"))); - } - let ts = doc.styles.resolve_table_style(&name); - let cells = doc.styles.cell.clone(); - let g = target(s, p, "style.table.apply")?; - edit_table(s, &g, "style.table.apply", |t| { - ts.apply_to(t, &cells); - ok() - }) -} - -fn table_style_edit(s: &mut Session, p: &Value) -> Result { - let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); - let border = stroke_param(s, p.get("border"), "style.table.edit")?; - let p = p.clone(); - s.edit(|d, _| { - let ts = - d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; - table_style_fields(ts, &p, border); - let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); - let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); - let cells = d.styles.cell.clone(); - for sid in d.stories.keys().copied().collect::>() { - let Some(st) = d.story_mut(sid) else { continue }; - for t in st.tables.values_mut() { - if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) { - ts.apply_to(std::sync::Arc::make_mut(t), &cells); - } - } - st.rev = st.rev.wrapping_add(1); - } - ok() - }) -} - -#[cfg(test)] -mod style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap(); - let got = s.execute("table.get", &json!({})).unwrap(); - let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); - assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); - assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); - // Cell Options › Text › Rotation: carried and written back, composed horizontally so far, - // which the IDML import reports (#1468). - for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] { - s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}")); - let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone(); - assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}"); - } - for bad in [45, 1, 100] { - assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}"); - } - assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); - s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); - s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); - let d = &s.doc().unwrap().doc; - let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap(); - assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading); - assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent); - } - - #[test] - fn cell_and_table_styles_apply_and_follow_edits() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Cell Head"})).unwrap(); - s.execute("style.cell.create", &json!({"name": "Head", "fill": "[Black]", "paragraphStyle": "Cell Head", "insets": 6})).unwrap(); - s.execute("style.cell.create", &json!({"name": "Body", "fill": "[Paper]"})).unwrap(); - s.execute("style.table.create", &json!({"name": "Data", "header": "Head", "body": "Body", "border": {"weight": 2, "color": "[Black]"}})) - .unwrap(); - s.execute("style.table.apply", &json!({"name": "Data"})).unwrap(); - let t = |s: &Session| -> designcraft_doc::Table { - let d = &s.doc().unwrap().doc; - (**d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap()).clone() - }; - let tb = t(&s); - assert_eq!(tb.style, "Data"); - assert_eq!(tb.options.border.weight, 2.0); - let head = tb.cell(0, 0).unwrap(); - assert_eq!((head.fill.as_str(), head.style.as_str(), head.insets), ("[Black]", "Head", [6.0; 4])); - assert_eq!(head.text.paras[0].style, "Cell Head"); - assert_eq!(tb.cell(1, 1).unwrap().style, "Body"); - // Editing the cell style updates its cells. - s.execute("style.cell.edit", &json!({"name": "Head", "fill": "C=100 M=0 Y=0 K=0"})).unwrap(); - assert_eq!(t(&s).cell(0, 1).unwrap().fill, "C=100 M=0 Y=0 K=0"); - // Editing the table style re-applies it. - s.execute("style.table.edit", &json!({"name": "Data", "border": {"weight": 4}})).unwrap(); - assert_eq!(t(&s).options.border.weight, 4.0); - assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err()); - } -} - -/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col). -fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> { - let st = s.active()?; - let loc = st.doc.find(frame)?; - let it = st.doc.item_at(&loc)?; - let sid = it.text_frame()?.story; - let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt; - let cs = s.cache.get(&st.doc, sid, None); - let fi = cs.frames.iter().position(|f| f.frame == frame)?; - let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?; - Some((sid, table, row, col)) -} - -fn drop_cells(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.dropCells"; - let frame = super::id_param(p, "frame").ok_or_else(|| bad(ID, "`frame` required"))?; - let pt = |k: &str| -> Result { - let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(ID, format!("`{k}` required")))?; - Ok(designcraft_geom::Point::new(a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0))) - }; - let (from, to) = (pt("from")?, pt("to")?); - let sel = s.doc()?.selection.cells; - let hit_from = cell_at(s, frame, from); - let hit_to = cell_at(s, frame, to); - if let (Some(ts), Some((sid, tid, fr, fc)), Some((_, tid2, tr, tc))) = (sel, hit_from, hit_to) - && ts.story == sid - && ts.table == tid - && tid2 == tid - && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) - { - let (nr, nc) = { - let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; - (t.nrows(), t.ncols()) - }; - let r = ts.range; - let rows = r.c0 == 0 && r.c1 + 1 == nc; - let cols = r.r0 == 0 && r.r1 + 1 == nr; - if rows || cols { - let (lo, hi, target) = if rows { (r.r0, r.r1, tr) } else { (r.c0, r.c1, tc) }; - if target >= lo && target <= hi { - return Ok(json!({"moved": 0})); - } - let n = hi - lo + 1; - let r2 = s.edit(|d, selm| { - let t = d.story_mut(sid).and_then(|st| st.table_mut(tid)).ok_or_else(|| bad(ID, "no table"))?; - let mv = |t: &mut designcraft_doc::Table, a: usize, b: usize| if rows { t.move_row(a, b) } else { t.move_col(a, b) }; - let new_lo = if target > hi { - for _ in 0..n { - mv(t, lo, target).map_err(|e| bad(ID, e))?; - } - target + 1 - n - } else { - for k in 0..n { - mv(t, lo + k, target + k).map_err(|e| bad(ID, e))?; - } - target - }; - let range = - if rows { CellRange::new(new_lo, r.c0, new_lo + n - 1, r.c1) } else { CellRange::new(r.r0, new_lo, r.r1, new_lo + n - 1) }; - selm.cells = Some(TableSel { range, ..ts }); - Ok(json!({"moved": n, "to": new_lo})) - })?; - return Ok(r2); - } - } - // Not a row/column drag: select text from the press to the release. - s.execute("text.placeCaret", &json!({"frame": frame.0, "point": [from.x, from.y]}))?; - s.execute("text.extendTo", &json!({"frame": frame.0, "point": [to.x, to.y]})) -} - -fn place_graphic(s: &mut Session, p: &Value) -> Result { - const ID: &str = "table.placeGraphic"; - let g = target(s, p, ID)?; - let (bytes, name, link) = super::file::read_source(p)?; - let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; - let (nw, nh) = designcraft_render::natural_size(&bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?; - let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); - // Compute fitting before the edit: a small EPS can overflow in a huge cell. - let t = s.doc()?.doc.story(g.story).and_then(|st| st.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; - let (r, c) = t.owner(g.range.r0, g.range.c0); - let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?; - let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize); - let w: f64 = t.columns.get(c..c.saturating_add(cs).min(t.ncols())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.width).sum(); - let h: f64 = t.rows.get(r..r.saturating_add(rs).min(t.nrows())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.height).sum(); - let ins = cell.insets; - let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0)); - let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) }; - let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k); - super::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?; - s.edit(|d, sel| { - let fresh = designcraft_doc::AssetId(d.alloc()); - let mime = designcraft_render::image_mime(&bytes).to_string(); - let aid = d.add_asset(designcraft_doc::Asset { - id: fresh, - name, - mime, - link, - data: std::sync::Arc::new(bytes), - pixels: Some((pw, ph)), - ..Default::default() - }); - let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; - let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; - let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; - cell.graphic = Some(designcraft_doc::Graphic { - asset: aid, - size: (nw, nh), - xf, - clip: None, - auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - clamp_selection(d, sel); - Ok(json!({"asset": aid.0, "row": r, "col": c})) - }) -} - -fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { - const ID: &str = "table.move"; - let g = target(s, p, ID)?; - let from = p.get("from").and_then(Value::as_u64).map_or(if rows { g.range.r0 } else { g.range.c0 }, |v| v as usize); - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`to` required"))? as usize; - edit_table(s, &g, ID, |t| { - if rows { t.move_row(from, to) } else { t.move_col(from, to) }.map_err(|e| bad(ID, e))?; - Ok(json!({"from": from, "to": to})) - }) -} - -fn sort_rows(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.sortRows")?; - let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize); - let desc = p.get("descending").and_then(Value::as_bool).unwrap_or(false); - edit_table(s, &g, "table.sortRows", |t| { - let (nr, nc) = (t.nrows(), t.ncols()); - if col >= nc { - return Err(bad("table.sortRows", format!("no column {col}"))); - } - if t.cells.iter().any(|c| c.row_span > 1) { - return Err(bad("table.sortRows", "unmerge cells that span rows first")); - } - let (h, f) = (t.header_rows(), t.footer_rows()); - let body: Vec = (h..nr - f).collect(); - let key = |r: usize| t.cell(r, col).map(|c| c.text.text.trim().to_string()).unwrap_or_default(); - let mut order = body.clone(); - order.sort_by(|a, b| { - let (ka, kb) = (key(*a), key(*b)); - let o = match (ka.replace(',', "").parse::(), kb.replace(',', "").parse::()) { - (Ok(x), Ok(y)) => x.total_cmp(&y), - _ => ka.to_lowercase().cmp(&kb.to_lowercase()), - }; - if desc { o.reverse() } else { o } - }); - let rows = t.rows.clone(); - let cells = t.cells.clone(); - for (k, src) in order.iter().enumerate() { - let dst = body[k]; - t.rows[dst] = rows[*src].clone(); - for c in 0..nc { - t.cells[dst * nc + c] = cells[src * nc + c].clone(); - } - } - Ok(json!({"sorted": body.len()})) - }) -} - -#[cfg(test)] -mod sort_tests { - use serde_json::json; - - use crate::Session; - - fn distribution_session() -> Session { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { - s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); - s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); - } - s - } - - fn table(s: &Session) -> designcraft_doc::Table { - (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() - } - - #[test] - fn distribute_selected_rows_preserves_height_content_and_undo() { - let mut s = distribution_session(); - s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); - let before = table(&s); - let selection = s.doc().unwrap().selection.cells; - let result = s.execute("table.distributeRows", &json!({})).unwrap(); - assert_eq!(result, json!({"rows": 2, "height": 60.0})); - let after = table(&s); - assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); - assert_eq!(after.cells, before.cells); - assert_eq!(after.rows[0].kind, before.rows[0].kind); - assert_eq!(s.doc().unwrap().selection.cells, selection); - let st = s.doc().unwrap(); - let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; - let cs = s.cache.get(&st.doc, sid, None); - let heights: Vec<_> = - cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); - assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(table(&s), before); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(table(&s), after); - } - - #[test] - fn distribution_uses_content_grown_heights_and_explicit_table() { - let mut s = distribution_session(); - s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); - s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); - let st = s.doc().unwrap(); - let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); - let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); - let cs = s.cache.get(&st.doc, sid, None); - let total: f64 = cs - .frames - .iter() - .flat_map(|f| &f.tables) - .flat_map(|f| &f.cells) - .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) - .map(|c| c.rect.height()) - .sum(); - let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); - assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); - assert!(table(&s).rows[1].height > 3.0); - assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); - } - - #[test] - fn distribution_keeps_column_spans_and_caret() { - let mut s = distribution_session(); - s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); - let before = table(&s); - let caret = s.doc().unwrap().selection.text; - let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); - assert_eq!(result["height"], 60.0); - assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); - assert_eq!(table(&s).cells, before.cells); - assert_eq!(s.doc().unwrap().selection.text, caret); - } - - #[test] - fn distribution_rejects_merges_and_missing_rows_without_changes() { - let mut s = distribution_session(); - s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); - let before = table(&s); - assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); - assert_eq!(table(&s), before); - s.execute("edit.undo", &json!({})).unwrap(); - s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); - let before = table(&s); - assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); - assert_eq!(table(&s), before); - assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); - for params in - [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] - { - assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); - assert_eq!(table(&s), before); - } - } - - #[test] - fn sort_body_rows_by_a_column() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - let cells = [("Name", "Qty"), ("pear", "10"), ("Apple", "9"), ("fig", "100")]; - for (r, (a, b)) in cells.iter().enumerate() { - s.execute("table.setCell", &json!({"rows": [r, r], "cols": [0, 0], "text": a})).unwrap(); - s.execute("table.setCell", &json!({"rows": [r, r], "cols": [1, 1], "text": b})).unwrap(); - } - let col = |s: &Session, c: usize| -> Vec { - let d = &s.doc().unwrap().doc; - let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - (0..t.nrows()).map(|r| t.cell(r, c).unwrap().text.text.clone()).collect() - }; - s.execute("table.sortRows", &json!({"column": 0})).unwrap(); - assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive"); - s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap(); - assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value"); - s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap(); - let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); - assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3))); - } - - #[test] - fn graphic_cells_draw_their_image() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - s.execute("table.rowHeight", &json!({"height": 100})).ok(); - // A red image into the first cell. - let px: Vec = (0..40 * 40).flat_map(|_| [255, 0, 0, 255]).collect(); - let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png(); - s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": ""})).ok(); - let g = s.execute("table.placeGraphic", &json!({"base64": super::super::file::base64_encode(&png), "name": "r.png", "fit": "fill"})).unwrap(); - assert_eq!((g["row"].as_u64(), g["col"].as_u64()), (Some(0), Some(0))); - let d = s.doc().unwrap().doc.clone(); - let st = d.stories.values().find(|st| !st.tables.is_empty()).unwrap(); - let t = st.tables.values().next().unwrap(); - let cs = s.cache.get(&d, st.id, None); - let cell = cs.frames[0].tables[0].cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap().clone(); - let fx = d.item(cs.frames[0].frame).unwrap().xf; - let centre = fx * cell.clip.center(); - let mut rr = designcraft_render::Renderer::new(); - rr.threads = 0; - let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); - let p = img.pixel(centre.x as u32, centre.y as u32); - assert!(p[0] > 200 && p[1] < 60, "red in the graphic cell: {p:?}"); - assert!(t.cell(0, 0).unwrap().graphic.is_some()); - s.execute("table.textCell", &json!({})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(d.stories.values().flat_map(|st| st.tables.values()).all(|t| t.cell(0, 0).unwrap().graphic.is_none())); - } - - #[test] - fn move_rows_and_columns() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap(); - for (r, c, t) in [(0, 0, "a"), (1, 0, "b"), (2, 0, "c"), (0, 1, "x")] { - s.execute("table.setCell", &json!({"rows": [r, r], "cols": [c, c], "text": t})).unwrap(); - } - let cell = |s: &Session, r: usize, c: usize| { - let d = &s.doc().unwrap().doc; - let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - t.cell(r, c).unwrap().text.text.clone() - }; - // Dragging a selected row onto the last row moves it there. - let d = s.doc().unwrap().doc.clone(); - let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); - let fid = d.stories[&sid].frames[0]; - let cs = s.cache.get(&d, sid, None); - let cells = &cs.frames[0].tables[0].cells; - let centre = |r: usize| { - let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); - d.text_xf(d.item(fid).unwrap()) * c - }; - let (from, to) = (centre(0), centre(2)); - s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); - let r2 = s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); - assert_eq!(r2["moved"], 1); - assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["a", "b", "c"]); - s.execute("table.moveRow", &json!({"from": 0, "to": 2})).unwrap(); - assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); - s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); - assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); - } - - #[test] - fn move_row_cannot_move_a_header_row_out_of_place() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); - assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - // Dragging the selected header row onto the last body row is refused too. - let d = s.doc().unwrap().doc.clone(); - let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); - let fid = d.stories[&sid].frames[0]; - let cs = s.cache.get(&d, sid, None); - let cells = &cs.frames[0].tables[0].cells; - let centre = |r: usize| { - let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); - d.text_xf(d.item(fid).unwrap()) * c - }; - let (from, to) = (centre(0), centre(2)); - s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); - assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err()); - s.doc().unwrap().doc.check().unwrap(); - } - - #[test] - fn table_options_limits_header_and_footer_rows() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); - // The limit `table.insert` has: the table used to grow by as many rows as were asked for. - s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); - let t = table(&s); - assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); - } -} - -#[cfg(test)] -mod arabic_tests { - use super::*; - #[test] - fn arabic_table_direction_and_physical_left_insertion_support_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 400, 300], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); - s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": "Right"})).unwrap(); - s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); - s.execute("table.select", &json!({"rows": [0, 0], "cols": [0, 0]})).unwrap(); - s.execute("table.insertColumnLeft", &json!({})).unwrap(); - let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - assert_eq!(t.ncols(), 3); - assert_eq!(t.cell(0, 0).unwrap().text.text, "Right"); - assert!(t.cell(0, 1).unwrap().text.text.is_empty()); - s.execute("edit.undo", &json!({})).unwrap(); - let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); - assert_eq!(t.ncols(), 2); - assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft); - } - - /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's - /// text selected. Returns the session and the story id. - fn styled_frame(direction: &str, text: &str) -> (Session, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap(); - s.execute("edit.selectAll", &json!({})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap(); - s.execute("edit.selectAll", &json!({})).unwrap(); - (s, r["story"].as_u64().unwrap()) - } - - fn only_table(s: &Session) -> std::sync::Arc { - s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone() - } - - /// Text converted to a table in a right-to-left paragraph laid its columns out left to right - /// (#100): the first column belongs on the right. - #[test] - fn tables_take_the_direction_of_their_paragraph() { - use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; - let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج"); - s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); - assert_eq!(only_table(&s).options.direction, RightToLeft); - let st = s.doc().unwrap(); - let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None); - let placed = &cs.frames[0].tables[0]; - let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect); - assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}"); - - let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc"); - s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); - assert_eq!(only_table(&s).options.direction, LeftToRight); - - for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { - let (mut s, _) = styled_frame(direction, "x"); - s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); - assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph"); - } - } -} - -#[cfg(test)] -mod resize_border_tests { - use serde_json::json; - - use crate::Session; - - /// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids. - fn session() -> (Session, u64, u64, u64) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap(); - (s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap()) - } - - fn table(s: &Session) -> designcraft_doc::Table { - (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() - } - - fn widths(s: &Session) -> Vec { - table(s).columns.iter().map(|c| c.width).collect() - } - - /// Composed cell rects of column 0, top to bottom. - fn rows(s: &Session) -> Vec { - let st = s.doc().unwrap(); - let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id; - let cs = s.cache.get(&st.doc, sid, None); - cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect() - } - - fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) { - p["frame"] = json!(ids.0); - p["story"] = json!(ids.1); - p["table"] = json!(ids.2); - s.execute("table.resizeBorder", &p).unwrap(); - } - - #[test] - fn column_border_drags_widen_the_table_or_keep_its_width() { - let (mut s, f, st, t) = session(); - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20})); - assert_eq!(widths(&s), [100.0, 80.0, 80.0]); - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true})); - assert_eq!(widths(&s), [100.0, 110.0, 50.0]); - // Clamped at the minimum; the neighbour takes only what is left. - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true})); - assert_eq!(widths(&s), [100.0, 157.0, 3.0]); - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000})); - assert_eq!(widths(&s), [3.0, 157.0, 3.0]); - // Shift on the end edge scales every column. - resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true})); - let w = widths(&s); - assert!((w.iter().sum::() - 326.0).abs() < 1e-9, "{w:?}"); - assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}"); - assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err()); - assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err()); - } - - #[test] - fn row_drag_sets_at_least_and_moves_later_rows() { - let (mut s, f, st, t) = session(); - s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap(); - let before = rows(&s); - resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12})); - let row = &table(&s).rows[0]; - assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast)); - let after = rows(&s); - assert!((after[0].height() - 42.0).abs() < 1e-9); - assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9); - // Below the minimum: clamped, never negative. - resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000})); - assert_eq!(table(&s).rows[0].height, 3.0); - } - - #[test] - fn a_drag_is_one_undo_step_and_locked_frames_refuse() { - let (mut s, f, st, t) = session(); - let before = table(&s); - s.begin_interaction("Resize Column").unwrap(); - for d in [5.0, 15.0, 25.0] { - let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d}); - s.preview("table.resizeBorder", p).unwrap(); - } - s.commit_interaction().unwrap(); - assert_eq!(widths(&s), [105.0, 80.0, 80.0]); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(table(&s), before); - s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap(); - assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); - } -} +mod cell; +mod content; +mod options; +mod registry; +mod selection; +mod sizing; +mod structure; +mod styles; +mod target; + +pub use registry::specs; +pub(crate) use selection::step_cell; diff --git a/crates/engine/src/cmd/table/cell.rs b/crates/engine/src/cmd/table/cell.rs new file mode 100644 index 000000000..93a1f3dab --- /dev/null +++ b/crates/engine/src/cmd/table/cell.rs @@ -0,0 +1,487 @@ +use super::target::{edit_table, target}; +use super::{CellRange, CellStroke, Document, Result, Session, StrokeType, Table, Value, VerticalJustification, bad, compose, json, ok, str_param}; + +/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and +/// right) and the lines between its rows and columns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct EdgeSel { + top: bool, + left: bool, + bottom: bool, + right: bool, + inner_h: bool, + inner_v: bool, +} + +impl EdgeSel { + const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true }; + + fn parse(v: Option<&Value>, cmd: &str) -> Result { + let names: Vec<&Value> = match v { + None | Some(Value::Null) => return Ok(EdgeSel::ALL), + Some(Value::Array(a)) => a.iter().collect(), + Some(v) => vec![v], + }; + let mut e = EdgeSel::default(); + for n in names { + match n.as_str() { + Some("all") => e = EdgeSel::ALL, + Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true), + Some("inner") => (e.inner_h, e.inner_v) = (true, true), + Some("top") => e.top = true, + Some("left") => e.left = true, + Some("bottom") => e.bottom = true, + Some("right") => e.right = true, + Some("innerHorizontal") => e.inner_h = true, + Some("innerVertical") => e.inner_v = true, + _ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))), + } + } + if e == EdgeSel::default() { + return Err(bad(cmd, "no edges chosen")); + } + Ok(e) + } + + /// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen + /// within `rg`. Left and right are physical sides, also in RTL tables. + fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] { + let last_row = r.saturating_add((spans.0.max(1) - 1) as usize); + let last_col = c.saturating_add((spans.1.max(1) - 1) as usize); + let (start, end) = (c <= rg.c0, last_col >= rg.c1); + let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) }; + [ + if r <= rg.r0 { self.top } else { self.inner_h }, + if left_outer { self.left } else { self.inner_v }, + if last_row >= rg.r1 { self.bottom } else { self.inner_h }, + if right_outer { self.right } else { self.inner_v }, + ] + } +} + +/// A finite number in `lo..=hi`, or an error naming `key`. +fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result> { + match v.get(key) { + None | Some(Value::Null) => Ok(None), + Some(x) => match x.as_f64() { + Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)), + _ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))), + }, + } +} + +/// A swatch of the document (or [None]), or an error naming `key`. +fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result> { + let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) }; + let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?; + if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() { + return Err(bad(cmd, format!("no swatch `{name}`"))); + } + Ok(Some(name.to_string())) +} + +/// The cell stroke type names of `type`. +const CELL_STROKE_TYPES: &[&str] = + &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; + +/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the +/// resulting weight of each edge. +pub(super) struct StrokeEdit { + attrs: designcraft_doc::CellStrokeAttrs, + scaled_dashes: bool, +} + +impl StrokeEdit { + pub(super) fn parse(doc: &Document, v: &Value, cmd: &str) -> Result { + if !v.is_object() { + return Err(bad(cmd, "stroke must be an object")); + } + let mut attrs = designcraft_doc::CellStrokeAttrs { + weight: num_in(v, "weight", 0.0, 1000.0, cmd)?, + color: swatch_in(doc, v, &["color"], cmd)?, + tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32), + gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?, + gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32), + kind: None, + }; + let mut scaled_dashes = false; + match v.get("type") { + None | Some(Value::Null) => {} + Some(Value::String(name)) if name == "dashed" => { + scaled_dashes = true; + attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() }); + } + Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => { + attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?); + } + Some(t @ Value::Object(_)) => { + let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?; + let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0); + match &kind { + StrokeType::Dashed { pattern } if !StrokeType::valid_dash_pattern(pattern) => { + return Err(bad(cmd, "a dash pattern needs 1 to 64 lengths, each 0 or 0.1 to 10000 pt, not all 0")); + } + StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => { + return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers")); + } + StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => { + return Err(bad(cmd, format!("no stroke style `{name}`"))); + } + _ => {} + } + attrs.kind = Some(kind); + } + Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))), + } + Ok(StrokeEdit { attrs, scaled_dashes }) + } + + pub(super) fn apply(&self, mut base: CellStroke) -> CellStroke { + self.attrs.apply_to(&mut base); + if self.scaled_dashes { + let w = base.weight.max(1.0); + base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] }; + } + base + } +} + +/// The stroke drawn on side `side` of the owner cell at `(r, c)` (where that side meets its +/// first neighbour), as rendering resolves it; the cell's own copy if the side has no stretch. +fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side: usize) -> Option { + let drawn = compose::table::cell_side_strokes(t, owners, (r, c), side).first().map(|s| s.stroke.clone()); + drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned())) +} + +/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt). +pub(super) fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> { + let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) { + Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?), + None => None, + }; + let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) { + Some(m) => Some(m), + None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")), + }, + None => None, + }; + Ok((fb, min)) +} + +fn mirror_edited_cell_edges( + table: &mut Table, + range: CellRange, + table_size: (usize, usize, bool), + stroke: Option<&(StrokeEdit, EdgeSel)>, + edited: &std::collections::HashSet<(usize, usize, usize)>, + owners: &[(usize, usize)], +) { + let (rows, columns, rtl) = table_size; + let mirror = |table: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| { + if edited.contains(&(from.0, from.1, edge)) { + mirror_cell_edge(table, owners, from, edge, to, opposite); + } + }; + let Some((_, edges)) = stroke else { return }; + if edges.top + && let Some(row) = range.r0.checked_sub(1) + { + for column in range.c0..=range.c1 { + mirror(table, (range.r0, column), 0, (row, column), 2); + } + } + if edges.bottom && range.r1.saturating_add(1) < rows { + for column in range.c0..=range.c1 { + mirror(table, (range.r1, column), 2, (range.r1 + 1, column), 0); + } + } + if (if rtl { edges.right } else { edges.left }) + && let Some(column) = range.c0.checked_sub(1) + { + for row in range.r0..=range.r1 { + mirror(table, (row, range.c0), if rtl { 3 } else { 1 }, (row, column), if rtl { 1 } else { 3 }); + } + } + if (if rtl { edges.left } else { edges.right }) && range.c1.saturating_add(1) < columns { + for row in range.r0..=range.r1 { + mirror(table, (row, range.c1), if rtl { 1 } else { 3 }, (row, range.c1 + 1), if rtl { 3 } else { 1 }); + } + } +} + +pub(super) fn set_cell(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.setCell"; + let g = target(s, p, ID)?; + let doc = &s.doc()?.doc; + let fill = swatch_in(doc, p, &["fill"], ID)?; + let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32); + let insets: Option<[f64; 4]> = match p.get("insets") { + Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]), + Some(Value::Array(a)) if a.len() == 4 => { + let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); + Some([v[0], v[1], v[2], v[3]]) + } + _ => None, + }; + let vj = match str_param(p, "vj").or_else(|| str_param(p, "verticalJustification")) { + Some("top") => Some(VerticalJustification::Top), + Some("center") => Some(VerticalJustification::Center), + Some("bottom") => Some(VerticalJustification::Bottom), + Some("justify") => Some(VerticalJustification::Justify), + Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), + None => None, + }; + let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?; + // Cell Options › Text › Rotation. The value is kept and written back to IDML; only 0 is + // composed so far, which the IDML import reports (#1468). + let rotation = match p.get("rotation").filter(|v| !v.is_null()) { + Some(v) => match v.as_f64().map(|d| d.rem_euclid(360.0)) { + Some(d) if d == 0.0 || d == 90.0 || d == 180.0 || d == 270.0 => Some(d), + _ => return Err(bad(ID, "rotation: 0, 90, 180 or 270 degrees")), + }, + None => None, + }; + let text = str_param(p, "text").map(|_| crate::cmd::text_param(p, "text")); + let stroke = match p.get("stroke").filter(|v| !v.is_null()) { + Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), + None => None, + }; + edit_table(s, &g, ID, |t| { + let owners = t.owners(); + let nc = t.ncols(); + let nr = t.nrows(); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + // Local edits outrank previously imported edges, including explicit zero/None. + let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 }; + let rg = g.range; + // Edges are inner or outer within the selection grown to whole merged cells, so a + // merged cell selected through one of its grid positions keeps its own outline. + let outline = t.expand_range(rg); + // Each chosen edge starts from the stroke drawn there, which both copies of a shared + // edge then take, so the edit changes only the attributes it names. + let mut bases = std::collections::HashMap::new(); + if let Some((_, edges)) = &stroke { + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell(r, c) else { continue }; + for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) { + bases.insert((r, c, i), base); + } + } + } + } + let mut edited = std::collections::HashSet::new(); + for (r, c) in t.selected_owners(rg) { + let Some(cell) = t.cell_mut(r, c) else { continue }; + if let Some(f) = &fill { + cell.fill = f.clone(); + } + if let Some(ti) = tint { + cell.fill_tint = ti; + } + if let Some(i) = insets { + cell.insets = i; + } + if let Some(v) = vj { + cell.vj = v; + } + if let Some(v) = first_baseline { + cell.first_baseline = v; + } + if let Some(v) = first_baseline_min { + cell.first_baseline_min = v; + } + if let Some(v) = rotation { + cell.rotation = v; + } + if let Some(tx) = &text { + let len = cell.text.len(); + cell.text.replace(0..len, tx); + } + if let Some((edit, _)) = &stroke { + for i in 0..4 { + if let Some(base) = bases.remove(&(r, c, i)) { + edited.insert((r, c, i)); + cell.strokes[i] = edit.apply(base); + cell.stroke_defined[i] = true; + cell.stroke_priorities[i] = priority; + cell.border_overrides[i] = true; + } + } + } + } + // Keep both copies of unmerged shared boundaries consistent, including in RTL tables. + mirror_edited_cell_edges(t, rg, (nr, nc, rtl), stroke.as_ref(), &edited, &owners); + ok() + }) +} + +/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values +/// that differ are null. +pub(super) fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.getCellStroke"; + let g = target(s, p, ID)?; + let edges = EdgeSel::parse(p.get("edges"), ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let owners = t.owners(); + let outline = t.expand_range(g.range); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + // One value per key, or null once two edges (cells) disagree. + fn merge(slot: &mut Option, v: Value) { + match slot { + None => *slot = Some(v), + Some(cur) if *cur != v => *cur = Value::Null, + _ => {} + } + } + let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"]; + let mut stroke: [Option; 6] = Default::default(); + let (mut fill, mut fill_tint) = (None, None); + // Each drawn edge once: a shared edge is a side of both its cells. + let mut seen = std::collections::HashSet::new(); + for (r, c) in t.selected_owners(g.range) { + let Some(cell) = t.cell(r, c) else { continue }; + merge(&mut fill, json!(cell.fill)); + merge(&mut fill_tint, json!(cell.fill_tint)); + for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if !on { + continue; + } + for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) { + let (x, y) = (Some((r, c)), side.neighbor); + if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) { + continue; + } + let e = side.stroke; + let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; + for (slot, v) in stroke.iter_mut().zip(values) { + merge(slot, v); + } + } + } + } + let mut out = serde_json::Map::new(); + for (k, v) in keys.iter().zip(stroke) { + out.insert((*k).into(), v.unwrap_or(Value::Null)); + } + out.insert("fill".into(), fill.unwrap_or(Value::Null)); + out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null)); + out.insert("edges".into(), json!(seen.len())); + Ok(Value::Object(out)) +} + +/// Only saturation needs rebasing. Preserve every distinct priority's order and keep +/// zero/negative priorities untouched; the work is bounded by the table's edges. +fn next_cell_stroke_priority(t: &mut Table) -> i32 { + let highest = t.cells.iter().flat_map(|cell| cell.stroke_priorities).max().unwrap_or(0).max(0); + if highest < i32::MAX { + return highest + 1; + } + let values: std::collections::BTreeSet<_> = t.cells.iter().flat_map(|cell| cell.stroke_priorities).filter(|p| *p > 0).collect(); + let mut ranks = std::collections::BTreeMap::new(); + let mut highest = 0i32; + for value in values { + highest = highest.saturating_add(1); + ranks.insert(value, highest); + } + for cell in &mut t.cells { + for priority in &mut cell.stroke_priorities { + if let Some(rank) = ranks.get(priority) { + *priority = *rank; + } + } + } + highest.saturating_add(1) +} + +fn mirror_cell_edge(t: &mut Table, owners: &[(usize, usize)], from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize) { + let owner = |pos: (usize, usize)| owners.get(pos.0.checked_mul(t.ncols())?.checked_add(pos.1)?).copied(); + if owner(from) != Some(from) || owner(to) != Some(to) { + return; + } + // A merged cell's edge may border several separately editable cells. Mirroring + // a segment onto that whole edge would change unselected neighbors; priorities + // instead resolve each segment without altering the merged cell's formatting. + let merged = |cell: &designcraft_doc::Cell| cell.row_span > 1 || cell.col_span > 1; + if t.cell(from.0, from.1).is_some_and(merged) || t.cell(to.0, to.1).is_some_and(merged) { + return; + } + let source = t + .cell(from.0, from.1) + .map(|cell| (cell.strokes[edge].clone(), cell.stroke_defined[edge], cell.stroke_priorities[edge], cell.border_overrides[edge])); + if let (Some((stroke, defined, priority, border_override)), Some(neighbor)) = (source, t.cell_mut(to.0, to.1)) { + neighbor.strokes[opposite] = stroke; + neighbor.stroke_defined[opposite] = defined; + neighbor.stroke_priorities[opposite] = priority; + neighbor.border_overrides[opposite] = border_override; + } +} + +#[cfg(test)] +mod arabic_tests { + use super::*; + #[test] + fn arabic_table_direction_and_physical_left_insertion_support_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 400, 300], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": "Right"})).unwrap(); + s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap(); + s.execute("table.select", &json!({"rows": [0, 0], "cols": [0, 0]})).unwrap(); + s.execute("table.insertColumnLeft", &json!({})).unwrap(); + let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + assert_eq!(t.ncols(), 3); + assert_eq!(t.cell(0, 0).unwrap().text.text, "Right"); + assert!(t.cell(0, 1).unwrap().text.text.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + assert_eq!(t.ncols(), 2); + assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft); + } + + /// A text frame whose paragraphs have a paragraph style with `direction`, with the story's + /// text selected. Returns the session and the story id. + fn styled_frame(direction: &str, text: &str) -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"preset": "A4", "pages": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Dir", "para": {"direction": direction}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [40, 330, 555, 420], "content": "text", "text": text, "caret": true})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Dir"})).unwrap(); + s.execute("edit.selectAll", &json!({})).unwrap(); + (s, r["story"].as_u64().unwrap()) + } + + fn only_table(s: &Session) -> std::sync::Arc { + s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap().clone() + } + + /// Text converted to a table in a right-to-left paragraph laid its columns out left to right + /// (#100): the first column belongs on the right. + #[test] + fn tables_take_the_direction_of_their_paragraph() { + use designcraft_doc::TextDirection::{LeftToRight, RightToLeft}; + let (mut s, sid) = styled_frame("rightToLeft", "١\t٢\t٣\nأ\tب\tج"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, RightToLeft); + let st = s.doc().unwrap(); + let cs = s.cache.get(&st.doc, designcraft_doc::StoryId(sid), None); + let placed = &cs.frames[0].tables[0]; + let (first, last) = (placed.cell(0, 0).unwrap().rect, placed.cell(0, 2).unwrap().rect); + assert!(first.x0 > last.x0, "cell ١ is the rightmost: {first:?} {last:?}"); + + let (mut s, _) = styled_frame("leftToRight", "1\t2\t3\na\tb\tc"); + s.execute("table.convertFromText", &json!({"columnSeparator": "tab"})).unwrap(); + assert_eq!(only_table(&s).options.direction, LeftToRight); + + for (direction, expected) in [("rightToLeft", RightToLeft), ("leftToRight", LeftToRight)] { + let (mut s, _) = styled_frame(direction, "x"); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + assert_eq!(only_table(&s).options.direction, expected, "table.insert in a {direction} paragraph"); + } + } +} diff --git a/crates/engine/src/cmd/table/content.rs b/crates/engine/src/cmd/table/content.rs new file mode 100644 index 000000000..2d306c811 --- /dev/null +++ b/crates/engine/src/cmd/table/content.rs @@ -0,0 +1,388 @@ +use super::selection::cell_at; +use super::target::{clamp_selection, edit_table, target}; +use super::{CellRange, Result, Session, TableSel, Value, bad, json}; + +pub(super) fn drop_cells(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.dropCells"; + let frame = crate::cmd::id_param(p, "frame").ok_or_else(|| bad(ID, "`frame` required"))?; + let pt = |k: &str| -> Result { + let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(ID, format!("`{k}` required")))?; + Ok(designcraft_geom::Point::new(a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0))) + }; + let (from, to) = (pt("from")?, pt("to")?); + let sel = s.doc()?.selection.cells; + let hit_from = cell_at(s, frame, from); + let hit_to = cell_at(s, frame, to); + if let (Some(ts), Some((sid, tid, fr, fc)), Some((_, tid2, tr, tc))) = (sel, hit_from, hit_to) + && ts.story == sid + && ts.table == tid + && tid2 == tid + && s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok()) + { + let (nr, nc) = { + let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?; + (t.nrows(), t.ncols()) + }; + let r = ts.range; + let rows = r.c0 == 0 && r.c1 + 1 == nc; + let cols = r.r0 == 0 && r.r1 + 1 == nr; + if rows || cols { + let (lo, hi, target) = if rows { (r.r0, r.r1, tr) } else { (r.c0, r.c1, tc) }; + if target >= lo && target <= hi { + return Ok(json!({"moved": 0})); + } + let n = hi - lo + 1; + let r2 = s.edit(|d, selm| { + let t = d.story_mut(sid).and_then(|st| st.table_mut(tid)).ok_or_else(|| bad(ID, "no table"))?; + let mv = |t: &mut designcraft_doc::Table, a: usize, b: usize| if rows { t.move_row(a, b) } else { t.move_col(a, b) }; + let new_lo = if target > hi { + for _ in 0..n { + mv(t, lo, target).map_err(|e| bad(ID, e))?; + } + target + 1 - n + } else { + for k in 0..n { + mv(t, lo + k, target + k).map_err(|e| bad(ID, e))?; + } + target + }; + let range = + if rows { CellRange::new(new_lo, r.c0, new_lo + n - 1, r.c1) } else { CellRange::new(r.r0, new_lo, r.r1, new_lo + n - 1) }; + selm.cells = Some(TableSel { range, ..ts }); + Ok(json!({"moved": n, "to": new_lo})) + })?; + return Ok(r2); + } + } + // Not a row/column drag: select text from the press to the release. + s.execute("text.placeCaret", &json!({"frame": frame.0, "point": [from.x, from.y]}))?; + s.execute("text.extendTo", &json!({"frame": frame.0, "point": [to.x, to.y]})) +} + +pub(super) fn place_graphic(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.placeGraphic"; + let g = target(s, p, ID)?; + let (bytes, name, link) = crate::cmd::file::read_source(p)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; + let (nw, nh) = designcraft_render::natural_size(&bytes, 0).ok_or_else(|| bad(ID, "invalid image dimensions"))?; + let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); + // Compute fitting before the edit: a small EPS can overflow in a huge cell. + let t = s.doc()?.doc.story(g.story).and_then(|st| st.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let (r, c) = t.owner(g.range.r0, g.range.c0); + let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?; + let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize); + let w: f64 = t.columns.get(c..c.saturating_add(cs).min(t.ncols())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.width).sum(); + let h: f64 = t.rows.get(r..r.saturating_add(rs).min(t.nrows())).ok_or_else(|| bad(ID, "invalid cell span"))?.iter().map(|x| x.height).sum(); + let ins = cell.insets; + let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0)); + let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) }; + let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k); + crate::cmd::file::validate_placement((nw, nh), designcraft_geom::Rect::new(0.0, 0.0, bw, bh), xf, ID)?; + s.edit(|d, sel| { + let fresh = designcraft_doc::AssetId(d.alloc()); + let mime = designcraft_render::image_mime(&bytes).to_string(); + let aid = d.add_asset(designcraft_doc::Asset { + id: fresh, + name, + mime, + link, + data: std::sync::Arc::new(bytes), + pixels: Some((pw, ph)), + ..Default::default() + }); + let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; + let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; + let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?; + cell.graphic = Some(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (nw, nh), + xf, + clip: None, + auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + clamp_selection(d, sel); + Ok(json!({"asset": aid.0, "row": r, "col": c})) + }) +} + +pub(super) fn sort_rows(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.sortRows")?; + let col = p.get("column").and_then(Value::as_u64).map_or(g.range.c0, |c| c as usize); + let desc = p.get("descending").and_then(Value::as_bool).unwrap_or(false); + edit_table(s, &g, "table.sortRows", |t| { + let (nr, nc) = (t.nrows(), t.ncols()); + if col >= nc { + return Err(bad("table.sortRows", format!("no column {col}"))); + } + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad("table.sortRows", "unmerge cells that span rows first")); + } + let (h, f) = (t.header_rows(), t.footer_rows()); + let body: Vec = (h..nr - f).collect(); + let key = |r: usize| t.cell(r, col).map(|c| c.text.text.trim().to_string()).unwrap_or_default(); + let mut order = body.clone(); + order.sort_by(|a, b| { + let (ka, kb) = (key(*a), key(*b)); + let o = match (ka.replace(',', "").parse::(), kb.replace(',', "").parse::()) { + (Ok(x), Ok(y)) => x.total_cmp(&y), + _ => ka.to_lowercase().cmp(&kb.to_lowercase()), + }; + if desc { o.reverse() } else { o } + }); + let rows = t.rows.clone(); + let cells = t.cells.clone(); + for (k, src) in order.iter().enumerate() { + let dst = body[k]; + t.rows[dst] = rows[*src].clone(); + for c in 0..nc { + t.cells[dst * nc + c] = cells[src * nc + c].clone(); + } + } + Ok(json!({"sorted": body.len()})) + }) +} + +#[cfg(test)] +mod sort_tests { + use serde_json::json; + + use crate::Session; + + fn distribution_session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + for (row, height) in [(0, 25), (1, 40), (2, 80), (3, 30)] { + s.execute("table.setRowHeight", &json!({"row": row, "height": height, "mode": "exactly"})).unwrap(); + s.execute("table.setCell", &json!({"row": row, "col": 0, "text": format!("row {row}")})).unwrap(); + } + s + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + #[test] + fn distribute_selected_rows_preserves_height_content_and_undo() { + let mut s = distribution_session(); + s.execute("table.select", &json!({"rows": [1, 2], "what": "row"})).unwrap(); + let before = table(&s); + let selection = s.doc().unwrap().selection.cells; + let result = s.execute("table.distributeRows", &json!({})).unwrap(); + assert_eq!(result, json!({"rows": 2, "height": 60.0})); + let after = table(&s); + assert_eq!(after.rows.iter().map(|r| r.height).collect::>(), [25.0, 60.0, 60.0, 30.0]); + assert_eq!(after.cells, before.cells); + assert_eq!(after.rows[0].kind, before.rows[0].kind); + assert_eq!(s.doc().unwrap().selection.cells, selection); + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + let heights: Vec<_> = + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect.height()).collect(); + assert_eq!(heights, [25.0, 60.0, 60.0, 30.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(table(&s), after); + } + + #[test] + fn distribution_uses_content_grown_heights_and_explicit_table() { + let mut s = distribution_session(); + s.execute("table.setRowHeight", &json!({"rows": [1, 2], "height": 3, "mode": "atLeast"})).unwrap(); + s.execute("table.setCell", &json!({"row": 2, "col": 0, "text": "one\ntwo\nthree"})).unwrap(); + let st = s.doc().unwrap(); + let story = st.doc.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let (sid, tid) = (story.id, *story.tables.keys().next().unwrap()); + let cs = s.cache.get(&st.doc, sid, None); + let total: f64 = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .flat_map(|f| &f.cells) + .filter(|c| c.col == 0 && (1..=2).contains(&c.row)) + .map(|c| c.rect.height()) + .sum(); + let result = s.execute("table.distributeRows", &json!({"story": sid.0, "table": tid, "rows": [1, 2]})).unwrap(); + assert!((result["height"].as_f64().unwrap() * 2.0 - total).abs() < 1e-6); + assert!(table(&s).rows[1].height > 3.0); + assert_eq!(table(&s).rows[1].mode, designcraft_doc::RowHeightMode::Exactly); + } + + #[test] + fn distribution_keeps_column_spans_and_caret() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 1], "cols": [0, 1]})).unwrap(); + let before = table(&s); + let caret = s.doc().unwrap().selection.text; + let result = s.execute("table.distributeRows", &json!({"rows": [2, 1]})).unwrap(); + assert_eq!(result["height"], 60.0); + assert_eq!(table(&s).cell(1, 0).unwrap().col_span, 2); + assert_eq!(table(&s).cells, before.cells); + assert_eq!(s.doc().unwrap().selection.text, caret); + } + + #[test] + fn distribution_rejects_merges_and_missing_rows_without_changes() { + let mut s = distribution_session(); + s.execute("table.merge", &json!({"rows": [1, 2], "cols": [0, 0]})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [1, 2]})).is_err()); + assert_eq!(table(&s), before); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("table.setRowHeight", &json!({"rows": [0, 3], "height": 10000, "mode": "exactly"})).unwrap(); + let before = table(&s); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 3]})).is_err()); + assert_eq!(table(&s), before); + assert!(s.execute("table.distributeRows", &json!({"rows": [0, 99]})).is_err()); + for params in + [json!({"rows": "all"}), json!({"rows": [0]}), json!({"rows": [-1, 2]}), json!({"row": 1.5}), json!({"table": "bad"}), json!([])] + { + assert!(s.execute("table.distributeRows", ¶ms).is_err(), "{params}"); + assert_eq!(table(&s), before); + } + } + + #[test] + fn sort_body_rows_by_a_column() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + let cells = [("Name", "Qty"), ("pear", "10"), ("Apple", "9"), ("fig", "100")]; + for (r, (a, b)) in cells.iter().enumerate() { + s.execute("table.setCell", &json!({"rows": [r, r], "cols": [0, 0], "text": a})).unwrap(); + s.execute("table.setCell", &json!({"rows": [r, r], "cols": [1, 1], "text": b})).unwrap(); + } + let col = |s: &Session, c: usize| -> Vec { + let d = &s.doc().unwrap().doc; + let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + (0..t.nrows()).map(|r| t.cell(r, c).unwrap().text.text.clone()).collect() + }; + s.execute("table.sortRows", &json!({"column": 0})).unwrap(); + assert_eq!(col(&s, 0), ["Name", "Apple", "fig", "pear"], "header stays; case-insensitive"); + s.execute("table.sortRows", &json!({"column": 1, "descending": true})).unwrap(); + assert_eq!(col(&s, 1), ["Qty", "100", "10", "9"], "numbers by value"); + s.execute("table.splitVertically", &json!({"rows": [1, 1], "cols": [0, 0]})).unwrap(); + let r = s.execute("table.splitHorizontally", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap(); + assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3))); + } + + #[test] + fn graphic_cells_draw_their_image() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.rowHeight", &json!({"height": 100})).ok(); + // A red image into the first cell. + let px: Vec = (0..40 * 40).flat_map(|_| [255, 0, 0, 255]).collect(); + let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png(); + s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": ""})).ok(); + let g = s.execute("table.placeGraphic", &json!({"base64": crate::cmd::file::base64_encode(&png), "name": "r.png", "fit": "fill"})).unwrap(); + assert_eq!((g["row"].as_u64(), g["col"].as_u64()), (Some(0), Some(0))); + let d = s.doc().unwrap().doc.clone(); + let st = d.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let t = st.tables.values().next().unwrap(); + let cs = s.cache.get(&d, st.id, None); + let cell = cs.frames[0].tables[0].cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap().clone(); + let fx = d.item(cs.frames[0].frame).unwrap().xf; + let centre = fx * cell.clip.center(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let p = img.pixel(centre.x as u32, centre.y as u32); + assert!(p[0] > 200 && p[1] < 60, "red in the graphic cell: {p:?}"); + assert!(t.cell(0, 0).unwrap().graphic.is_some()); + s.execute("table.textCell", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.stories.values().flat_map(|st| st.tables.values()).all(|t| t.cell(0, 0).unwrap().graphic.is_none())); + } + + #[test] + fn move_rows_and_columns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2})).unwrap(); + for (r, c, t) in [(0, 0, "a"), (1, 0, "b"), (2, 0, "c"), (0, 1, "x")] { + s.execute("table.setCell", &json!({"rows": [r, r], "cols": [c, c], "text": t})).unwrap(); + } + let cell = |s: &Session, r: usize, c: usize| { + let d = &s.doc().unwrap().doc; + let t = d.stories.values().flat_map(|st| st.tables.values()).next().unwrap(); + t.cell(r, c).unwrap().text.text.clone() + }; + // Dragging a selected row onto the last row moves it there. + let d = s.doc().unwrap().doc.clone(); + let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); + let fid = d.stories[&sid].frames[0]; + let cs = s.cache.get(&d, sid, None); + let cells = &cs.frames[0].tables[0].cells; + let centre = |r: usize| { + let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); + d.text_xf(d.item(fid).unwrap()) * c + }; + let (from, to) = (centre(0), centre(2)); + s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); + let r2 = s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap(); + assert_eq!(r2["moved"], 1); + assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["a", "b", "c"]); + s.execute("table.moveRow", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!([cell(&s, 0, 0), cell(&s, 1, 0), cell(&s, 2, 0)], ["b", "c", "a"]); + s.execute("table.moveColumn", &json!({"from": 1, "to": 0})).unwrap(); + assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string())); + } + + #[test] + fn move_row_cannot_move_a_header_row_out_of_place() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + // Dragging the selected header row onto the last body row is refused too. + let d = s.doc().unwrap().doc.clone(); + let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap(); + let fid = d.stories[&sid].frames[0]; + let cs = s.cache.get(&d, sid, None); + let cells = &cs.frames[0].tables[0].cells; + let centre = |r: usize| { + let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center(); + d.text_xf(d.item(fid).unwrap()) * c + }; + let (from, to) = (centre(0), centre(2)); + s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap(); + assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err()); + s.doc().unwrap().doc.check().unwrap(); + } + + #[test] + fn table_options_limits_header_and_footer_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 700], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + // The limit `table.insert` has: the table used to grow by as many rows as were asked for. + s.execute("table.options", &json!({"headerRows": 5000, "footerRows": 5000})).unwrap(); + let t = table(&s); + assert_eq!((t.header_rows(), t.footer_rows(), t.nrows()), (100, 100, 201)); + } +} diff --git a/crates/engine/src/cmd/table/options.rs b/crates/engine/src/cmd/table/options.rs new file mode 100644 index 000000000..7f9ed7786 --- /dev/null +++ b/crates/engine/src/cmd/table/options.rs @@ -0,0 +1,88 @@ +use super::cell::StrokeEdit; +use super::target::{color_param, edit_table, target}; +use super::{AltFills, Result, Session, Value, bad, f64_or, json}; + +fn parse_alt(v: &Value) -> Option { + if v.is_null() { + return None; + } + let d = AltFills::default(); + Some(AltFills { + first: v.get("first").and_then(Value::as_u64).map_or(d.first, |x| u32::try_from(x).unwrap_or(u32::MAX)), + first_color: color_param(v, "firstColor").unwrap_or(d.first_color), + first_tint: v.get("firstTint").and_then(Value::as_f64).map_or(d.first_tint, |x| x as f32), + next: v.get("next").and_then(Value::as_u64).map_or(d.next, |x| u32::try_from(x).unwrap_or(u32::MAX)), + next_color: color_param(v, "nextColor").unwrap_or(d.next_color), + next_tint: v.get("nextTint").and_then(Value::as_f64).map_or(d.next_tint, |x| x as f32), + skip_first: v.get("skipFirst").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), + skip_last: v.get("skipLast").and_then(Value::as_u64).map_or(0, |x| u32::try_from(x).unwrap_or(u32::MAX)), + }) +} + +pub(super) fn options(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.options")?; + let border = match p.get("border").filter(|v| !v.is_null()) { + Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?), + None => None, + }; + let p = p.clone(); + edit_table(s, &g, "table.options", |t| { + let o = &mut t.options; + if let Some(v) = p.get("direction") { + o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?; + } + if let Some(b) = &border { + o.border = b.apply(o.border.clone()); + o.borders = Default::default(); + } + o.space_before = f64_or(&p, "spaceBefore", o.space_before); + o.space_after = f64_or(&p, "spaceAfter", o.space_after); + if let Some(b) = p.get("repeatHeader").and_then(Value::as_bool) { + o.repeat_header = b; + } + if let Some(b) = p.get("repeatFooter").and_then(Value::as_bool) { + o.repeat_footer = b; + } + if let Some(v) = p.get("altRows") { + o.alt_rows = parse_alt(v); + } + if let Some(v) = p.get("altCols") { + o.alt_cols = parse_alt(v); + } + // At most 100 of each, as in `table.insert`: the rows the table lacks are added below. + let h = p.get("headerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + let f = p.get("footerRows").and_then(Value::as_u64).map(|v| v.min(100) as usize); + if h.is_some() || f.is_some() { + let (h, f) = (h.unwrap_or(t.header_rows()), f.unwrap_or(t.footer_rows())); + // Add rows when there aren't enough for the requested header/footer plus one body row. + let need = h + f + 1; + if t.nrows() < need { + let at = t.nrows(); + t.insert_rows(at, need - at); + } + t.set_header_footer(h, f); + } + Ok(json!({"options": t.options})) + }) +} + +pub(super) fn get(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.get")?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.get", "no table"))?; + let owners = t.owners(); + let cells: Vec = (0..t.nrows()) + .flat_map(|r| (0..t.ncols()).map(move |c| (r, c))) + .filter(|&(r, c)| owners[r * t.ncols() + c] == (r, c)) + .filter_map(|(r, c)| { + let cell = t.cell(r, c)?; + Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, + "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "rotation": cell.rotation, "style": cell.style})) + }) + .collect(); + Ok(json!({ + "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), + "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), + "cells": cells, "options": t.options, "range": g.range, "style": t.style, + })) +} diff --git a/crates/engine/src/cmd/table/registry.rs b/crates/engine/src/cmd/table/registry.rs new file mode 100644 index 000000000..28d451ced --- /dev/null +++ b/crates/engine/src/cmd/table/registry.rs @@ -0,0 +1,402 @@ +use super::cell::{get_cell_stroke, set_cell}; +use super::content::{drop_cells, place_graphic, sort_rows}; +use super::options::{get, options}; +use super::selection::{move_rc, select, step_cell}; +use super::sizing::{distribute_cols, distribute_rows, resize_border, set_col_width, set_row_height}; +use super::structure::{ + can_convert_rows, convert_from_text, convert_rows, convert_to_text, delete_cols, delete_rows, delete_table, insert, insert_col, insert_row, + merge, split, unmerge, +}; +use super::styles::{cell_style_apply, cell_style_create, cell_style_edit, table_style_apply, table_style_create, table_style_edit}; +use super::target::{edit_table, in_cell, in_table, in_table_or_ids, target}; +use super::{CommandSpec, RowKind, cmd, has_doc, has_text, json, ok}; + +pub fn specs() -> Vec { + let mut v = style_specs(); + v.extend(table_specs()); + v +} + +fn style_specs() -> Vec { + vec![ + cmd!( + "style.cell.create", + "New Cell Style…", + [], + None, + "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}", + has_doc, + |s, p| cell_style_create(s, p) + ), + cmd!( + "style.cell.apply", + "Apply Cell Style", + [], + None, + "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — the target cells (the whole table when the cursor is in it)", + in_table, + cell_style_apply + ), + cmd!( + "style.cell.edit", + "Cell Style Options…", + [], + None, + "{name, …same as style.cell.create} — cells using the style follow", + has_doc, + cell_style_edit + ), + cmd!( + "style.table.create", + "New Table Style…", + [], + None, + "{name, basedOn?: table style name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", + has_doc, + table_style_create + ), + cmd!( + "style.table.apply", + "Apply Table Style", + [], + None, + "{name, story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?}", + in_table, + table_style_apply + ), + cmd!( + "style.table.edit", + "Table Style Options…", + [], + None, + "{name, …same as style.table.create} — tables using the style follow", + has_doc, + table_style_edit + ), + ] +} + +fn table_specs() -> Vec { + vec![ + cmd!( + "table.insert", + "Create Table", + [], + Some("Cmd+Alt+Shift+T"), + "{rows?: body rows (4), cols? | columns? (4), headerRows? (0), footerRows? (0), width?} — at the text insertion point", + has_text, + insert + ), + cmd!( + "table.convertFromText", + "Convert Text to Table", + ["Table"], + None, + "{columnSeparator?: tab|comma, width?: table width (default: the column)} — selected paragraphs become rows", + has_text, + convert_from_text + ), + cmd!( + "table.convertToText", + "Convert Table to Text", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — cells separated by tabs, rows by paragraphs", + in_table, + convert_to_text + ), + cmd!( + "table.insertRow", + "Insert Row", + [], + None, + "{where?: above|below, count? (1), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_row(s, p, None) + ), + cmd!( + "table.insertRowAbove", + "Insert Row Above", + ["Table", "Insert"], + None, + "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_row(s, p, Some(true)) + ), + cmd!( + "table.insertRowBelow", + "Insert Row Below", + ["Table", "Insert"], + None, + "{count?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_row(s, p, Some(false)) + ), + cmd!( + "table.insertColumn", + "Insert Column", + [], + None, + "{where?: left|right, count? (1), width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_col(s, p, None) + ), + cmd!( + "table.insertColumnLeft", + "Insert Column Left", + ["Table", "Insert"], + None, + "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_col(s, p, Some(true)) + ), + cmd!( + "table.insertColumnRight", + "Insert Column Right", + ["Table", "Insert"], + None, + "{count?, width?, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + |s, p| insert_col(s, p, Some(false)) + ), + cmd!( + "table.deleteRow", + "Delete Row", + ["Table", "Delete"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the rows of the target cells", + in_table, + delete_rows + ), + cmd!( + "table.deleteColumn", + "Delete Column", + ["Table", "Delete"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the columns of the target cells", + in_table, + delete_cols + ), + cmd!( + "table.delete", + "Delete Table", + ["Table", "Delete"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + delete_table + ), + cmd!( + "table.merge", + "Merge Cells", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — merge the target cell range", + in_table, + merge + ), + cmd!( + "table.dropCells", + "Drag Rows or Columns", + [], + None, + "{frame, from: [x, y], to: [x, y]} — selected whole rows (or columns) dragged from `from` move to the row (column) at `to`; otherwise a text drag", + has_doc, + drop_cells + ), + cmd!( + "table.placeGraphic", + "Convert Cell to Graphic Cell", + ["Table", "Convert Cell Type"], + None, + "{path? | base64?, name?: with base64, fit?: proportional|fill (default proportional), story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — an image in the target cell (its text is kept but hidden)", + in_table, + place_graphic + ), + cmd!( + "table.textCell", + "Convert Cell to Text Cell", + ["Table", "Convert Cell Type"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — drop the target cells' graphics", + in_table, + |s, p| { + let g = target(s, p, "table.textCell")?; + edit_table(s, &g, "table.textCell", |t| { + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cell.graphic = None; + } + } + ok() + }) + } + ), + cmd!( + "table.moveRow", + "Move Row", + [], + None, + "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target row) — like dragging a row", + in_table, + |s, p| move_rc(s, p, true) + ), + cmd!( + "table.moveColumn", + "Move Column", + [], + None, + "{from?, to, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} (0-based; from defaults to the target column)", + in_table, + |s, p| move_rc(s, p, false) + ), + cmd!( + "table.sortRows", + "Sort", + ["Table"], + None, + "{column?: 0-based (default: the target cell's), descending?: bool, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — body rows by that column's text (numbers numerically)", + in_table, + sort_rows + ), + cmd!( + "table.unmerge", + "Unmerge Cells", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + unmerge + ), + cmd!( + "table.splitHorizontally", + "Split Cell Horizontally", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, one above the other", + in_table, + |s, p| split(s, p, true) + ), + cmd!( + "table.splitVertically", + "Split Cell Vertically", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — each target cell becomes two, side by side", + in_table, + |s, p| split(s, p, false) + ), + cmd!( + "table.setCell", + "Cell Options", + [], + None, + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, rotation?: 0|90|180|270 (Cell Options › Text › Rotation; kept and written back to IDML, composed horizontally for now), text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", + in_table, + set_cell + ), + cmd!( + query "table.getCellStroke", + "Get Cell Stroke", + [], + None, + "{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null", + in_table_or_ids, + get_cell_stroke + ), + cmd!( + "table.setRowHeight", + "Row Height", + [], + None, + "{height, mode?: atLeast|exactly, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + set_row_height + ), + cmd!( + "table.setColumnWidth", + "Column Width", + [], + None, + "{width, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + set_col_width + ), + cmd!( + "table.resizeBorder", + "Resize Row or Column", + [], + None, + "{story?, table?, frame?, axis: column|row, index, delta? | size?, keepTableSize?: bool} — moves the border after column/row `index` by `delta` (or makes that column/row `size` wide/high); later columns/rows move along. The last index is the table's end/bottom edge. keepTableSize: on an inner border the next column/row gives or takes the change; on the edge all columns/rows scale proportionally. A dragged row becomes At Least. Refuses a locked `frame`", + in_table_or_ids, + resize_border + ), + cmd!( + "table.distributeColumns", + "Distribute Columns Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + distribute_cols + ), + cmd!( + "table.distributeRows", + "Distribute Rows Evenly", + ["Table"], + None, + "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} — equal fixed heights preserving the selected rows' composed total height; may overset cell text; unmerge row-spanning cells first", + in_table, + distribute_rows + ), + cmd!( + "table.options", + "Table Options", + [], + None, + "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", + in_table, + options + ), + cmd!( + "table.convertRowsToHeader", + "To Header", + ["Table", "Convert Rows"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the top of the table or right after its header rows", + |s| can_convert_rows(s, RowKind::Header), + |s, p| convert_rows(s, p, RowKind::Header) + ), + cmd!( + "table.convertRowsToBody", + "To Body", + ["Table", "Convert Rows"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target header rows next to the body, footer rows next to it, or both", + |s| can_convert_rows(s, RowKind::Body), + |s, p| convert_rows(s, p, RowKind::Body) + ), + cmd!( + "table.convertRowsToFooter", + "To Footer", + ["Table", "Convert Rows"], + None, + "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — the target rows, at the bottom of the table or right before its footer rows", + |s| can_convert_rows(s, RowKind::Footer), + |s, p| convert_rows(s, p, RowKind::Footer) + ), + cmd!(noundo "table.select", "Select Cells", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?, what?: cell|row|column|table}", in_table_or_ids, select), + cmd!(noundo "table.selectTable", "Select Table", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { + select(s, &crate::cmd::with_param(p, "what", json!("table"))) + }), + cmd!(noundo "table.selectRow", "Select Row", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { + select(s, &crate::cmd::with_param(p, "what", json!("row"))) + }), + cmd!(noundo "table.selectColumn", "Select Column", ["Table", "Select"], None, "{story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)}", in_table, |s, p| { + select(s, &crate::cmd::with_param(p, "what", json!("column"))) + }), + cmd!(noundo "table.nextCell", "Next Cell", [], None, "{}", in_cell, |s, _| step_cell(s, true)), + cmd!(noundo "table.prevCell", "Previous Cell", [], None, "{}", in_cell, |s, _| step_cell(s, false)), + cmd!(query "table.get", "Get Table", [], None, "{story?, table?, rows?: [a,b] | row?, cols?: [a,b] | col?} → rows, columns, cells (text, spans, fill), options", in_table_or_ids, get), + ] +} diff --git a/crates/engine/src/cmd/table/selection.rs b/crates/engine/src/cmd/table/selection.rs new file mode 100644 index 000000000..9e0a11d0e --- /dev/null +++ b/crates/engine/src/cmd/table/selection.rs @@ -0,0 +1,91 @@ +use super::target::{Tgt, edit_table, target}; +use super::{CellAddr, CellRange, Result, Selection, Session, StoryId, TableSel, TextSel, Value, bad, json, ok, str_param}; + +// ---------- selection ---------- + +pub(super) fn select(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.select")?; + let (nr, nc) = { + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad("table.select", "no table"))?; + (t.nrows(), t.ncols()) + }; + let mut r = g.range; + match str_param(p, "what") { + Some("table") => r = CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }, + Some("row") => { + r.c0 = 0; + r.c1 = nc - 1; + } + Some("column") => { + r.r0 = 0; + r.r1 = nr - 1; + } + _ => {} + } + let st = s.doc_mut()?; + let frame = st.selection.text.and_then(|t| t.frame); + st.selection = Selection { + cells: Some(TableSel { story: g.story, table: g.table, range: r }), + text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), + ..Default::default() + }; + st.bump_revision(); + Ok(json!({"story": g.story.0, "table": g.table, "range": r})) +} + +/// Move the caret to the next/previous cell (Tab / Shift-Tab), selecting its text. Tab in the last +/// cell adds a row. +pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { + let t = s.doc()?.selection.text.ok_or_else(|| bad("table.nextCell", "no caret"))?; + let c = t.cell.ok_or_else(|| bad("table.nextCell", "not in a cell"))?; + let (owners, nr, nc) = { + let st = s.doc()?; + let tb = st.doc.story(t.story).and_then(|x| x.tables.get(&c.table)).ok_or_else(|| bad("table.nextCell", "no table"))?; + (tb.owners(), tb.nrows(), tb.ncols()) + }; + let cur = c.row * nc + c.col; + let next = + if forward { (cur + 1..nr * nc).find(|&i| owners[i] == (i / nc, i % nc)) } else { (0..cur).rev().find(|&i| owners[i] == (i / nc, i % nc)) }; + let (row, col) = match next { + Some(i) => (i / nc, i % nc), + None if forward => { + let g = Tgt { story: t.story, table: c.table, range: CellRange::cell(nr - 1, 0) }; + edit_table(s, &g, "table.nextCell", |tb| { + tb.insert_rows(nr, 1); + ok() + })?; + (nr, 0) + } + None => return ok(), + }; + let st = s.doc_mut()?; + let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len()); + st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t }); + st.bump_revision(); + Ok(json!({"row": row, "col": col})) +} + +/// The table cell under spread point `pt` in text frame `frame`: (story, table, row, col). +pub(super) fn cell_at(s: &Session, frame: designcraft_doc::ItemId, pt: designcraft_geom::Point) -> Option<(StoryId, u64, usize, usize)> { + let st = s.active()?; + let loc = st.doc.find(frame)?; + let it = st.doc.item_at(&loc)?; + let sid = it.text_frame()?.story; + let inner = (st.doc.parent_xf(&loc) * st.doc.text_xf(it)).inverse() * pt; + let cs = s.cache.get(&st.doc, sid, None); + let fi = cs.frames.iter().position(|f| f.frame == frame)?; + let (table, row, col, _) = designcraft_compose::hit_cell(&cs, fi, inner)?; + Some((sid, table, row, col)) +} + +pub(super) fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { + const ID: &str = "table.move"; + let g = target(s, p, ID)?; + let from = p.get("from").and_then(Value::as_u64).map_or(if rows { g.range.r0 } else { g.range.c0 }, |v| v as usize); + let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(ID, "`to` required"))? as usize; + edit_table(s, &g, ID, |t| { + if rows { t.move_row(from, to) } else { t.move_col(from, to) }.map_err(|e| bad(ID, e))?; + Ok(json!({"from": from, "to": to})) + }) +} diff --git a/crates/engine/src/cmd/table/sizing.rs b/crates/engine/src/cmd/table/sizing.rs new file mode 100644 index 000000000..b097af87f --- /dev/null +++ b/crates/engine/src/cmd/table/sizing.rs @@ -0,0 +1,322 @@ +use super::target::{edit_table, target}; +use super::{Result, RowHeightMode, Session, Table, Value, bad, compose, json, ok, str_param}; + +pub(super) fn set_row_height(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.setRowHeight")?; + let h = p.get("height").and_then(Value::as_f64).map(|h| h.clamp(0.0, 10000.0)); + let mode = match str_param(p, "mode") { + Some("exactly") => Some(RowHeightMode::Exactly), + Some("atLeast") => Some(RowHeightMode::AtLeast), + Some(o) => return Err(bad("table.setRowHeight", format!("unknown mode `{o}`"))), + None => None, + }; + if h.is_none() && mode.is_none() { + return Err(bad("table.setRowHeight", "missing height")); + } + edit_table(s, &g, "table.setRowHeight", |t| { + for row in &mut t.rows[g.range.r0..=g.range.r1] { + if let Some(h) = h { + row.height = h; + } + if let Some(m) = mode { + row.mode = m; + } + } + ok() + }) +} + +pub(super) fn set_col_width(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.setColumnWidth")?; + let w = p.get("width").and_then(Value::as_f64).ok_or_else(|| bad("table.setColumnWidth", "missing width"))?.clamp(3.0, 10000.0); + edit_table(s, &g, "table.setColumnWidth", |t| { + for c in &mut t.columns[g.range.c0..=g.range.c1] { + c.width = w; + } + ok() + }) +} + +/// Smallest column width and row height a border drag leaves. +const MIN_TRACK: f64 = 3.0; + +const MAX_TRACK: f64 = 10000.0; + +/// Composed height of `row` (laid out from cells that span only that row), else its model height. +fn composed_row_height(cs: &compose::ComposedStory, t: &Table, row: usize) -> Option { + let laid_out = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == t.id) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row && t.cell(c.row, c.col).is_some_and(|x| x.row_span <= 1)) + .map(|c| c.rect.height()) + .filter(|h| h.is_finite() && *h > 0.0) + .reduce(f64::max); + laid_out.or_else(|| t.rows.get(row).map(|r| r.height)) +} + +/// Move the border after `index` among `sizes` by `delta`. Returns the new sizes. +fn resize_track(sizes: &[f64], index: usize, delta: f64, keep: bool) -> Option> { + let mut out = sizes.to_vec(); + let cur = *sizes.get(index)?; + let last = index + 1 == sizes.len(); + if keep && last { + // Scale every track so the table edge moves by `delta`. + let total: f64 = sizes.iter().sum(); + let smallest = sizes.iter().copied().fold(f64::INFINITY, f64::min); + let largest = sizes.iter().copied().fold(0.0, f64::max); + if !(total > 0.0 && smallest > 0.0 && largest > 0.0) { + return None; + } + let lo = (MIN_TRACK / smallest).min(1.0); + let hi = (MAX_TRACK / largest).max(1.0); + let f = ((total + delta) / total).clamp(lo, hi); + out.iter_mut().for_each(|v| *v *= f); + } else if keep { + let next = *sizes.get(index + 1)?; + let pair = cur + next; + let a = (cur + delta).clamp(MIN_TRACK, (pair - MIN_TRACK).max(MIN_TRACK)); + let [this, after] = out.get_mut(index..index + 2)? else { return None }; + *this = a; + *after = (pair - a).max(MIN_TRACK); + } else { + *out.get_mut(index)? = (cur + delta).clamp(MIN_TRACK, MAX_TRACK); + } + out.iter().all(|v| v.is_finite()).then_some(out) +} + +/// The frame a border drag names must hold the table's story and be editable. +fn check_border_frame(st: &crate::DocState, p: &Value, story: designcraft_doc::StoryId) -> Result<()> { + const ID: &str = "table.resizeBorder"; + if let Some(fid) = crate::cmd::id_param(p, "frame") { + let it = st.doc.item(fid).ok_or_else(|| bad(ID, format!("no frame {}", fid.0)))?; + if it.text_frame().map(|f| f.story) != Some(story) { + return Err(bad(ID, "the table is not in that frame's story")); + } + if it.locked || st.doc.layer(it.layer).is_some_and(|l| l.locked) { + return Err(bad(ID, "the frame or its layer is locked")); + } + } + Ok(()) +} + +/// The change in track size: `delta`, or `size` measured from the current size `cur`. +fn border_delta(p: &Value, cur: f64) -> Result { + const ID: &str = "table.resizeBorder"; + let delta = match (p.get("delta").and_then(Value::as_f64), p.get("size").and_then(Value::as_f64)) { + (Some(d), _) => d, + (None, Some(sz)) => sz - cur, + (None, None) => return Err(bad(ID, "`delta` or `size` required")), + }; + if !delta.is_finite() { + return Err(bad(ID, "`delta`/`size` must be a finite number")); + } + Ok(delta) +} + +pub(super) fn resize_border(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.resizeBorder"; + let column = match str_param(p, "axis") { + Some("column") => true, + Some("row") => false, + _ => return Err(bad(ID, "axis must be `column` or `row`")), + }; + let index = p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(ID, "`index` required"))?; + let keep = p.get("keepTableSize").and_then(Value::as_bool).unwrap_or(false); + let g = target(s, p, ID)?; + let st = s.doc()?; + check_border_frame(st, p, g.story)?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let sizes: Vec = if column { + t.columns.iter().map(|c| c.width).collect() + } else { + let cs = s.cache.get(&st.doc, g.story, None); + (0..t.nrows()).map(|r| composed_row_height(&cs, t, r).unwrap_or(MIN_TRACK)).collect() + }; + let cur = *sizes.get(index).ok_or_else(|| bad(ID, format!("index {index} out of range")))?; + let delta = border_delta(p, cur)?; + let new = resize_track(&sizes, index, delta, keep).ok_or_else(|| bad(ID, "invalid table geometry"))?; + let size = new.get(index).copied().unwrap_or(cur); + let scaled = keep && index + 1 == sizes.len(); + edit_table(s, &g, ID, |t| { + if column { + for (c, w) in t.columns.iter_mut().zip(&new) { + c.width = *w; + } + } else { + for (i, (r, h)) in t.rows.iter_mut().zip(&new).enumerate() { + // Only the rows the drag changed take the new height. + if scaled || i == index || (keep && i == index + 1) { + r.height = *h; + } + if i == index && !scaled { + r.mode = RowHeightMode::AtLeast; + } + } + } + Ok(json!({"axis": if column { "column" } else { "row" }, "index": index, "size": size})) + }) +} + +pub(super) fn distribute_rows(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.distributeRows"; + if !p.is_object() { + return Err(bad(ID, "parameters must be an object")); + } + if let Some(rows) = p.get("rows") { + let valid = rows.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(|v| v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()))); + if !valid { + return Err(bad(ID, "rows must be two non-negative row indices")); + } + } + for key in ["row", "story", "table"] { + if let Some(v) = p.get(key) + && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) + { + return Err(bad(ID, format!("{key} must be a non-negative integer"))); + } + } + let g = target(s, p, ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + if t.cells.iter().any(|c| c.row_span > 1) { + return Err(bad(ID, "unmerge cells that span rows first")); + } + let cs = s.cache.get(&st.doc, g.story, None); + let mut total = 0.0; + for row in g.range.r0..=g.range.r1 { + let height = cs + .frames + .iter() + .flat_map(|f| &f.tables) + .filter(|f| f.table == g.table) + .flat_map(|f| &f.cells) + .filter(|c| c.row == row) + .map(|c| c.rect.height()) + .reduce(f64::max) + .ok_or_else(|| bad(ID, "all selected rows must be laid out; enlarge or thread the text frame first"))?; + if !height.is_finite() || height <= 0.0 { + return Err(bad(ID, "invalid composed row height")); + } + total += height; + } + let count = g.range.r1 - g.range.r0 + 1; + let height = total / count as f64; + if !height.is_finite() { + return Err(bad(ID, "invalid total row height")); + } + edit_table(s, &g, ID, |t| { + let rows = t.rows.get_mut(g.range.r0..=g.range.r1).ok_or_else(|| bad(ID, "invalid row range"))?; + for row in rows { + row.height = height; + row.mode = RowHeightMode::Exactly; + } + Ok(json!({"rows": count, "height": height})) + }) +} + +pub(super) fn distribute_cols(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.distributeColumns")?; + edit_table(s, &g, "table.distributeColumns", |t| { + let cols = &mut t.columns[g.range.c0..=g.range.c1]; + let avg = cols.iter().map(|c| c.width).sum::() / cols.len() as f64; + cols.iter_mut().for_each(|c| c.width = avg); + ok() + }) +} + +#[cfg(test)] +mod resize_border_tests { + use serde_json::json; + + use crate::Session; + + /// A 3×3 table of 80 pt columns; returns the session, frame id, story and table ids. + fn session() -> (Session, u64, u64, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 472, 600], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let t = s.execute("table.insert", &json!({"rows": 3, "cols": 3, "width": 240})).unwrap(); + (s, r["id"].as_u64().unwrap(), t["story"].as_u64().unwrap(), t["table"].as_u64().unwrap()) + } + + fn table(s: &Session) -> designcraft_doc::Table { + (**s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap()).clone() + } + + fn widths(s: &Session) -> Vec { + table(s).columns.iter().map(|c| c.width).collect() + } + + /// Composed cell rects of column 0, top to bottom. + fn rows(s: &Session) -> Vec { + let st = s.doc().unwrap(); + let sid = st.doc.stories.values().find(|x| !x.tables.is_empty()).unwrap().id; + let cs = s.cache.get(&st.doc, sid, None); + cs.frames.iter().flat_map(|f| &f.tables).flat_map(|f| &f.cells).filter(|c| c.col == 0).map(|c| c.rect).collect() + } + + fn resize(s: &mut Session, ids: (u64, u64, u64), mut p: serde_json::Value) { + p["frame"] = json!(ids.0); + p["story"] = json!(ids.1); + p["table"] = json!(ids.2); + s.execute("table.resizeBorder", &p).unwrap(); + } + + #[test] + fn column_border_drags_widen_the_table_or_keep_its_width() { + let (mut s, f, st, t) = session(); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": 20})); + assert_eq!(widths(&s), [100.0, 80.0, 80.0]); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 30, "keepTableSize": true})); + assert_eq!(widths(&s), [100.0, 110.0, 50.0]); + // Clamped at the minimum; the neighbour takes only what is left. + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 1, "delta": 500, "keepTableSize": true})); + assert_eq!(widths(&s), [100.0, 157.0, 3.0]); + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 0, "delta": -1000})); + assert_eq!(widths(&s), [3.0, 157.0, 3.0]); + // Shift on the end edge scales every column. + resize(&mut s, (f, st, t), json!({"axis": "column", "index": 2, "size": 166, "keepTableSize": true})); + let w = widths(&s); + assert!((w.iter().sum::() - 326.0).abs() < 1e-9, "{w:?}"); + assert!((w[1] / w[0] - 157.0 / 3.0).abs() < 1e-9, "{w:?}"); + assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 3, "delta": 1})).is_err()); + assert!(s.execute("table.resizeBorder", &json!({"table": t, "axis": "column", "index": 0})).is_err()); + } + + #[test] + fn row_drag_sets_at_least_and_moves_later_rows() { + let (mut s, f, st, t) = session(); + s.execute("table.setRowHeight", &json!({"table": t, "row": 0, "height": 30, "mode": "exactly"})).unwrap(); + let before = rows(&s); + resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": 12})); + let row = &table(&s).rows[0]; + assert_eq!((row.height, row.mode), (42.0, designcraft_doc::RowHeightMode::AtLeast)); + let after = rows(&s); + assert!((after[0].height() - 42.0).abs() < 1e-9); + assert!((after[1].y0 - before[1].y0 - 12.0).abs() < 1e-9 && (after[1].height() - before[1].height()).abs() < 1e-9); + // Below the minimum: clamped, never negative. + resize(&mut s, (f, st, t), json!({"axis": "row", "index": 0, "delta": -1000})); + assert_eq!(table(&s).rows[0].height, 3.0); + } + + #[test] + fn a_drag_is_one_undo_step_and_locked_frames_refuse() { + let (mut s, f, st, t) = session(); + let before = table(&s); + s.begin_interaction("Resize Column").unwrap(); + for d in [5.0, 15.0, 25.0] { + let p = json!({"frame": f, "story": st, "table": t, "axis": "column", "index": 0, "delta": d}); + s.preview("table.resizeBorder", p).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(widths(&s), [105.0, 80.0, 80.0]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&s), before); + s.execute("object.setFlags", &json!({"ids": [f], "locked": true})).unwrap(); + assert!(s.execute("table.resizeBorder", &json!({"frame": f, "table": t, "axis": "column", "index": 0, "delta": 5})).is_err()); + } +} diff --git a/crates/engine/src/cmd/table/structure.rs b/crates/engine/src/cmd/table/structure.rs new file mode 100644 index 000000000..174704c4a --- /dev/null +++ b/crates/engine/src/cmd/table/structure.rs @@ -0,0 +1,240 @@ +use super::target::{column_width, count, edit_table, in_cell, para_direction, target}; +use super::{CellAddr, Result, RowKind, Selection, Session, Table, TextSel, Value, bad, json, ok, str_param}; + +pub(super) fn insert(s: &mut Session, p: &Value) -> Result { + let t = s.doc()?.selection.text.ok_or_else(|| bad("table.insert", "no insertion point"))?; + if t.cell.is_some() { + return Err(bad("table.insert", "tables can't be nested in cells")); + } + let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(4).clamp(1, 500) as usize; + let cols = p.get("cols").or_else(|| p.get("columns")).and_then(Value::as_u64).unwrap_or(4).clamp(1, 200) as usize; + let header = p.get("headerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + let footer = p.get("footerRows").and_then(Value::as_u64).unwrap_or(0).min(100) as usize; + let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); + s.edit(|d, sel| { + let id = d.alloc(); + let mut table = Table::new(id, rows, cols, header, footer, width); + table.options.direction = para_direction(d, t.story, t.range().start); + let st = d.story_mut(t.story).ok_or_else(|| bad("table.insert", "no story"))?; + // Cells start with the paragraph format at the caret. + let pf = st.paras[st.para_at(t.range().start)].clone(); + let cf = st.char_format_at(t.range().start).clone(); + for c in &mut table.cells { + c.text.paras[0] = designcraft_doc::ParaFormat { table: None, ..pf.clone() }; + c.text.chars[0].format = cf.clone(); + } + let r = t.range(); + st.delete(r.clone()); + st.insert_table(r.start, table); + *sel = Selection::text(TextSel { story: t.story, anchor: 0, focus: 0, frame: t.frame, cell: Some(CellAddr { table: id, row: 0, col: 0 }) }); + Ok(json!({"story": t.story.0, "table": id})) + }) +} + +pub(super) fn convert_from_text(s: &mut Session, p: &Value) -> Result { + let t = s.doc()?.selection.text.ok_or_else(|| bad("table.convertFromText", "no text selected"))?; + if t.cell.is_some() { + return Err(bad("table.convertFromText", "select text outside tables")); + } + let sep = if str_param(p, "columnSeparator") == Some("comma") { ',' } else { '\t' }; + let width = p.get("width").and_then(Value::as_f64).unwrap_or_else(|| column_width(s, &t)); + s.edit(|d, sel| { + let id = d.alloc(); + let direction = para_direction(d, t.story, t.range().start); + let st = d.story_mut(t.story).ok_or_else(|| bad("table.convertFromText", "no story"))?; + let ranges = st.para_ranges(); + let (pa, pb) = (st.para_at(t.range().start), st.para_at(t.range().end)); + if (pa..=pb).any(|i| st.paras[i].table.is_some()) { + return Err(bad("table.convertFromText", "the selection contains a table")); + } + let (a, b) = (ranges[pa].start, ranges[pb].end); + let data: Vec> = st.text[a..b].split('\n').map(|line| line.split(sep).map(|c| c.trim().to_string()).collect()).collect(); + let pf = st.paras[pa].clone(); + let cf = st.format_after(a).clone(); + let mut table = Table::from_strings(id, &data, width, &pf, &cf); + table.options.direction = direction; + st.delete(a..b); + let anchor = st.insert_table(a, table); + *sel = Selection::text(TextSel::caret(t.story, anchor)); + Ok(json!({"table": id, "rows": data.len()})) + }) +} + +pub(super) fn convert_to_text(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.convertToText")?; + s.edit(|d, sel| { + let st = d.story_mut(g.story).ok_or_else(|| bad("table.convertToText", "no story"))?; + let text = st.tables.get(&g.table).map(|t| t.plain_text()).unwrap_or_default(); + let a = st.table_anchor(g.table).ok_or_else(|| bad("table.convertToText", "no anchor"))?; + st.replace(a..a + designcraft_doc::TABLE_ANCHOR.len_utf8(), &text); + *sel = Selection::text(TextSel::caret(g.story, a + text.len())); + ok() + }) +} + +pub(super) fn delete_table(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.delete")?; + s.edit(|d, sel| { + let st = d.story_mut(g.story).ok_or_else(|| bad("table.delete", "no story"))?; + let a = st.table_anchor(g.table).ok_or_else(|| bad("table.delete", "no anchor"))?; + // Remove the anchor paragraph (with one separator) so no empty paragraph is left behind. + let end = a + designcraft_doc::TABLE_ANCHOR.len_utf8(); + let r = if &st.text[end..] == "\n" && a > 0 && st.text[..a].ends_with('\n') { + // Inserted at the end of the story: remove the empty paragraph after it too. + a - 1..end + 1 + } else if st.text[end..].starts_with('\n') { + a..end + 1 + } else if a > 0 && st.text[..a].ends_with('\n') { + a - 1..end + } else { + a..end + }; + st.delete(r.clone()); + *sel = Selection::text(TextSel::caret(g.story, r.start.min(st.len()))); + ok() + }) +} + +// ---------- rows and columns ---------- + +pub(super) fn insert_row(s: &mut Session, p: &Value, above: Option) -> Result { + let g = target(s, p, "table.insertRow")?; + let above = above.unwrap_or(str_param(p, "where") == Some("above")); + let n = count(p); + let at = if above { g.range.r0 } else { g.range.r1 + 1 }; + let r = edit_table(s, &g, "table.insertRow", |t| { + t.insert_rows(at, n); + Ok(json!({"rows": t.nrows()})) + })?; + shift_selection(s, g.table, at, n, true); + Ok(r) +} + +/// After inserting rows/columns at `at`, selections at or after it follow their cells. +fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool) { + let Ok(st) = s.doc_mut() else { return }; + if let Some(t) = st.selection.text.as_mut() + && let Some(c) = t.cell.as_mut() + && c.table == table + { + let v = if rows { &mut c.row } else { &mut c.col }; + if *v >= at { + *v += n; + } + } + if let Some(ts) = st.selection.cells.as_mut() + && ts.table == table + { + let (a, b) = if rows { (&mut ts.range.r0, &mut ts.range.r1) } else { (&mut ts.range.c0, &mut ts.range.c1) }; + if *a >= at { + *a += n; + } + if *b >= at { + *b += n; + } + } +} + +pub(super) fn insert_col(s: &mut Session, p: &Value, left: Option) -> Result { + let g = target(s, p, "table.insertColumn")?; + let left = left.unwrap_or(str_param(p, "where") == Some("left")); + let rtl = s + .doc()? + .doc + .story(g.story) + .and_then(|st| st.tables.get(&g.table)) + .is_some_and(|t| t.options.direction == designcraft_doc::TextDirection::RightToLeft); + let n = count(p); + let at = if left != rtl { g.range.c0 } else { g.range.c1 + 1 }; + let width = p.get("width").and_then(Value::as_f64); + let r = edit_table(s, &g, "table.insertColumn", |t| { + let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width); + t.insert_cols(at, n, w); + Ok(json!({"columns": t.ncols()})) + })?; + shift_selection(s, g.table, at, n, false); + Ok(r) +} + +pub(super) fn delete_rows(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.deleteRow")?; + let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.r0 == 0 && g.range.r1 + 1 >= t.nrows()); + if all { + return delete_table(s, p); + } + edit_table(s, &g, "table.deleteRow", |t| { + t.delete_rows(g.range.r0, g.range.r1 - g.range.r0 + 1); + Ok(json!({"rows": t.nrows()})) + }) +} + +pub(super) fn delete_cols(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.deleteColumn")?; + let all = s.doc()?.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).is_some_and(|t| g.range.c0 == 0 && g.range.c1 + 1 >= t.ncols()); + if all { + return delete_table(s, p); + } + edit_table(s, &g, "table.deleteColumn", |t| { + t.delete_cols(g.range.c0, g.range.c1 - g.range.c0 + 1); + Ok(json!({"columns": t.ncols()})) + }) +} + +pub(super) fn merge(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.merge")?; + let r = edit_table(s, &g, "table.merge", |t| { + t.merge(g.range).map_err(|e| bad("table.merge", e))?; + Ok(t.expand_range(g.range)) + })?; + // The caret goes to the merged cell. + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { + story: g.story, + anchor: 0, + focus: 0, + frame: st.selection.text.and_then(|t| t.frame), + cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }), + }); + ok() +} + +pub(super) fn split(s: &mut Session, p: &Value, horizontal: bool) -> Result { + let g = target(s, p, "table.split")?; + edit_table(s, &g, "table.split", |t| { + t.split_cells(g.range, horizontal).map_err(|e| bad("table.split", e))?; + Ok(json!({"rows": t.nrows(), "cols": t.ncols()})) + }) +} + +pub(super) fn unmerge(s: &mut Session, p: &Value) -> Result { + let g = target(s, p, "table.unmerge")?; + edit_table(s, &g, "table.unmerge", |t| { + for r in g.range.r0..=g.range.r1 { + for c in g.range.c0..=g.range.c1 { + t.unmerge(r, c); + } + } + ok() + }) +} + +/// Convert Rows is greyed out unless the selected cells (or the caret's cell) can change kind. +pub(super) fn can_convert_rows(s: &Session, kind: RowKind) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.selection.cells.is_none() { + in_cell(s)?; + } + let g = target(s, &Value::Null, "table.convertRows").map_err(|e| e.to_string())?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or("no table")?; + t.convert_rows_counts(g.range.r0, g.range.r1, kind).map(|_| ()) +} + +pub(super) fn convert_rows(s: &mut Session, p: &Value, kind: RowKind) -> Result { + const ID: &str = "table.convertRows"; + let g = target(s, p, ID)?; + edit_table(s, &g, ID, |t| { + let (h, f) = t.convert_rows_counts(g.range.r0, g.range.r1, kind).map_err(|e| bad(ID, e))?; + t.set_header_footer(h, f); + Ok(json!({"headerRows": h, "footerRows": f})) + }) +} diff --git a/crates/engine/src/cmd/table/styles.rs b/crates/engine/src/cmd/table/styles.rs new file mode 100644 index 000000000..fa3717977 --- /dev/null +++ b/crates/engine/src/cmd/table/styles.rs @@ -0,0 +1,350 @@ +use super::cell::{StrokeEdit, first_baseline_params}; +use super::target::{color_param, edit_table, target}; +use super::{CellStroke, Document, Result, Session, Value, bad, json, ok, str_param}; + +// ---------- cell and table styles ---------- + +/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default +/// edge, validated like `table.setCell`'s stroke. +fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { + match v.filter(|v| !v.is_null()) { + Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))), + None => Ok(None), + } +} + +/// Fill in the cell style fields given in `p`. +fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option, cmd: &str) -> Result<()> { + if let Some(v) = p.get("basedOn") { + cs.based_on = v.as_str().map(str::to_string); + } + if let Some(f) = color_param(p, "fill") { + cs.fill = Some(f); + } + if let Some(t) = p.get("tint").and_then(Value::as_f64) { + cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); + } + match p.get("insets") { + Some(Value::Number(n)) => { + cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]); + cs.inset_overrides = Default::default(); + } + Some(Value::Array(a)) if a.len() == 4 => { + let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); + cs.insets = Some([v[0], v[1], v[2], v[3]]); + cs.inset_overrides = Default::default(); + } + _ => {} + } + if let Some(v) = p.get("vj") { + cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); + } + let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?; + if first_baseline.is_some() { + cs.first_baseline = first_baseline; + } + if first_baseline_min.is_some() { + cs.first_baseline_min = first_baseline_min; + } + if let Some(st) = stroke { + cs.stroke = Some(st); + cs.strokes = Default::default(); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + cs.paragraph_style = Some(ps.to_string()); + } + Ok(()) +} + +pub(super) fn cell_style_create(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Cell Style 1").to_string(); + let mut cs = designcraft_doc::CellStyle::default(); + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) { + let g = target(s, p, "style.cell.create")?; + let d = &s.doc()?.doc; + let cell = d + .story(g.story) + .and_then(|st| st.tables.get(&g.table)) + .and_then(|t| { + let (r, c) = t.owner(g.range.r0, g.range.c0); + t.cell(r, c) + }) + .ok_or_else(|| bad("style.cell.create", "no cell"))?; + cs.fill = Some(cell.fill.clone()); + cs.fill_tint = Some(cell.fill_tint); + cs.insets = Some(cell.insets); + cs.vj = Some(cell.vj); + cs.first_baseline = Some(cell.first_baseline); + cs.first_baseline_min = Some(cell.first_baseline_min); + cs.stroke = Some(cell.strokes[0].clone()); + cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); + } + let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?; + cell_style_fields(&mut cs, p, stroke, "style.cell.create")?; + s.edit(|d, _| { + let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base); + cs.name = name.clone(); + d.styles_mut().cell.push(cs); + Ok(json!({"name": name})) + }) +} + +pub(super) fn cell_style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); + let styles = &s.doc()?.doc.styles.cell; + if !styles.iter().any(|c| c.name == name) { + return Err(bad("style.cell.apply", format!("no cell style `{name}`"))); + } + let cs = designcraft_doc::resolve_cell_style(styles, &name); + let g = target(s, p, "style.cell.apply")?; + edit_table(s, &g, "style.cell.apply", |t| { + let mut n = 0; + for (r, c) in t.selected_owners(g.range) { + if let Some(cell) = t.cell_mut(r, c) { + cs.apply_to(cell); + n += 1; + } + } + Ok(json!({"cells": n})) + }) +} + +/// Re-apply cell styles named in `names` to every cell using them (after a style edit). +fn reapply_cell_styles(d: &mut Document, names: &[String]) { + let styles: Vec<_> = names.iter().map(|name| designcraft_doc::resolve_cell_style(&d.styles.cell, name)).collect(); + for sid in d.stories.keys().copied().collect::>() { + let Some(st) = d.story_mut(sid) else { continue }; + for t in st.tables.values_mut() { + if !t.cells.iter().any(|c| names.contains(&c.style)) { + continue; + } + for cell in &mut std::sync::Arc::make_mut(t).cells { + if names.contains(&cell.style) + && let Some(cs) = styles.iter().find(|x| x.name == cell.style) + { + cs.apply_to(cell); + } + } + } + st.rev = st.rev.wrapping_add(1); + } +} + +/// Include descendants when editing an inherited style, with the resolver's depth limit. +fn dependent_styles<'a>(name: &str, styles: impl Iterator)>) -> Vec { + let styles: Vec<_> = styles.collect(); + styles + .iter() + .filter(|(candidate, _)| { + let mut current = Some(*candidate); + for _ in 0..32 { + let Some(current_name) = current else { return false }; + if current_name == name { + return true; + } + current = styles.iter().find(|(n, _)| *n == current_name).and_then(|(_, base)| *base); + } + false + }) + .map(|(name, _)| (*name).to_string()) + .collect() +} + +pub(super) fn cell_style_edit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); + let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?; + let p = p.clone(); + s.edit(|d, _| { + let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; + cell_style_fields(cs, &p, stroke, "style.cell.edit")?; + let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); + reapply_cell_styles(d, &names); + ok() + }) +} + +fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option) { + if let Some(v) = p.get("basedOn") { + ts.based_on = v.as_str().map(str::to_string); + } + for (k, f) in [ + ("header", &mut ts.header), + ("body", &mut ts.body), + ("footer", &mut ts.footer), + ("leftColumn", &mut ts.left_column), + ("rightColumn", &mut ts.right_column), + ] { + if let Some(v) = p.get(k) { + *f = v.as_str().map(str::to_string); + } + } + if let Some(b) = border { + ts.border = Some(b); + ts.borders = Default::default(); + } + if let Some(a) = p.get("altRows").and_then(Value::as_object) { + let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| u32::try_from(v).unwrap_or(u32::MAX)); + let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); + let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); + ts.alt_rows = Some(designcraft_doc::AltFills { + first: n("first", 1), + first_color: c("firstColor"), + first_tint: t("firstTint"), + next: n("next", 1), + next_color: c("nextColor"), + next_tint: t("nextTint"), + skip_first: n("skipFirst", 0), + skip_last: n("skipLast", 0), + }); + } + if let Some(v) = p.get("spaceBefore").and_then(Value::as_f64) { + ts.space_before = Some(v); + } + if let Some(v) = p.get("spaceAfter").and_then(Value::as_f64) { + ts.space_after = Some(v); + } +} + +pub(super) fn table_style_create(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); + let mut ts = designcraft_doc::TableStyle::default(); + let border = stroke_param(s, p.get("border"), "style.table.create")?; + table_style_fields(&mut ts, p, border); + s.edit(|d, _| { + let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base); + ts.name = name.clone(); + d.styles_mut().table.push(ts); + Ok(json!({"name": name})) + }) +} + +pub(super) fn table_style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); + let doc = &s.doc()?.doc; + if !doc.styles.table.iter().any(|c| c.name == name) { + return Err(bad("style.table.apply", format!("no table style `{name}`"))); + } + let ts = doc.styles.resolve_table_style(&name); + let cells = doc.styles.cell.clone(); + let g = target(s, p, "style.table.apply")?; + edit_table(s, &g, "style.table.apply", |t| { + ts.apply_to(t, &cells); + ok() + }) +} + +pub(super) fn table_style_edit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); + let border = stroke_param(s, p.get("border"), "style.table.edit")?; + let p = p.clone(); + s.edit(|d, _| { + let ts = + d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; + table_style_fields(ts, &p, border); + let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); + let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); + let cells = d.styles.cell.clone(); + for sid in d.stories.keys().copied().collect::>() { + let Some(st) = d.story_mut(sid) else { continue }; + for t in st.tables.values_mut() { + if let Some(ts) = styles.iter().find(|ts| ts.name == t.style) { + ts.apply_to(std::sync::Arc::make_mut(t), &cells); + } + } + st.rev = st.rev.wrapping_add(1); + } + ok() + }) +} + +#[cfg(test)] +mod style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap(); + let got = s.execute("table.get", &json!({})).unwrap(); + let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone(); + assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0))); + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err()); + // Cell Options › Text › Rotation: carried and written back, composed horizontally so far, + // which the IDML import reports (#1468). + for (given, want) in [(90, 90.0), (270, 270.0), (0, 0.0), (-90, 270.0), (450, 90.0)] { + s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": given})).unwrap_or_else(|e| panic!("{given}: {e}")); + let cell = s.execute("table.get", &json!({})).unwrap()["cells"][0].clone(); + assert_eq!(cell["rotation"].as_f64(), Some(want), "{given}"); + } + for bad in [45, 1, 100] { + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "rotation": bad})).is_err(), "{bad}"); + } + assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err()); + s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap(); + let d = &s.doc().unwrap().doc; + let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap(); + assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading); + assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent); + } + + #[test] + fn cell_and_table_styles_apply_and_follow_edits() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Cell Head"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Head", "fill": "[Black]", "paragraphStyle": "Cell Head", "insets": 6})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Body", "fill": "[Paper]"})).unwrap(); + s.execute("style.table.create", &json!({"name": "Data", "header": "Head", "body": "Body", "border": {"weight": 2, "color": "[Black]"}})) + .unwrap(); + s.execute("style.table.apply", &json!({"name": "Data"})).unwrap(); + let t = |s: &Session| -> designcraft_doc::Table { + let d = &s.doc().unwrap().doc; + (**d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap()).clone() + }; + let tb = t(&s); + assert_eq!(tb.style, "Data"); + assert_eq!(tb.options.border.weight, 2.0); + let head = tb.cell(0, 0).unwrap(); + assert_eq!((head.fill.as_str(), head.style.as_str(), head.insets), ("[Black]", "Head", [6.0; 4])); + assert_eq!(head.text.paras[0].style, "Cell Head"); + assert_eq!(tb.cell(1, 1).unwrap().style, "Body"); + // Editing the cell style updates its cells. + s.execute("style.cell.edit", &json!({"name": "Head", "fill": "C=100 M=0 Y=0 K=0"})).unwrap(); + assert_eq!(t(&s).cell(0, 1).unwrap().fill, "C=100 M=0 Y=0 K=0"); + // Editing the table style re-applies it. + s.execute("style.table.edit", &json!({"name": "Data", "border": {"weight": 4}})).unwrap(); + assert_eq!(t(&s).options.border.weight, 4.0); + assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err()); + } +} + +#[cfg(test)] +mod style_alt_rows_tests { + use serde_json::json; + + use super::table_style_fields; + + #[test] + fn style_alt_rows_counts_saturate() { + let mut ts = designcraft_doc::TableStyle::default(); + let p = json!({"altRows": {"first": 4294967297u64, "next": 4294967296u64, "skipFirst": 4294967295u64, "skipLast": 3}}); + table_style_fields(&mut ts, &p, None); + let a = ts.alt_rows.unwrap(); + assert_eq!(a.first, u32::MAX, "first"); + assert_eq!(a.next, u32::MAX, "next"); + assert_eq!(a.skip_first, u32::MAX, "skip_first"); + assert_eq!(a.skip_last, 3, "skip_last"); + } +} diff --git a/crates/engine/src/cmd/table/target.rs b/crates/engine/src/cmd/table/target.rs new file mode 100644 index 000000000..beaa1c8b0 --- /dev/null +++ b/crates/engine/src/cmd/table/target.rs @@ -0,0 +1,160 @@ +use super::{ + CellAddr, CellRange, Document, Result, Selection, Session, StoryId, Table, TableSel, TextSel, Value, bad, compose, floor_char_boundary, has_doc, + str_param, +}; + +// ---------- enablement and targeting ---------- + +pub(super) fn in_table(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let st = s.active().ok_or("no document open")?; + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + return Ok(()); + } + if let Some(t) = st.selection.text + && st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some()) + { + return Ok(()); + } + Err("place the insertion point in a table".into()) +} + +pub(super) fn in_table_or_ids(s: &Session) -> std::result::Result<(), String> { + has_doc(s) +} + +pub(super) fn in_cell(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + if s.active().is_some_and(|d| d.selection.text.is_some_and(|t| t.cell.is_some())) { Ok(()) } else { Err("not in a table cell".into()) } +} + +/// The table a command acts on and its target cell range. +pub(super) struct Tgt { + pub(super) story: StoryId, + pub(super) table: u64, + pub(super) range: CellRange, +} + +fn find_table(doc: &Document, id: u64) -> Option { + doc.stories.iter().find(|(_, s)| s.tables.contains_key(&id)).map(|(k, _)| *k) +} + +fn pair(p: &Value, many: &str, one: &str) -> Option<(usize, usize)> { + if let Some(a) = p.get(many).and_then(Value::as_array) { + let a0 = a.first()?.as_u64()? as usize; + let a1 = a.get(1).and_then(Value::as_u64).map_or(a0, |v| v as usize); + return Some((a0.min(a1), a0.max(a1))); + } + p.get(one).and_then(Value::as_u64).map(|v| (v as usize, v as usize)) +} + +pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let st = s.doc()?; + let doc = &st.doc; + let explicit_table = p.get("table").and_then(Value::as_u64); + let explicit_story = p.get("story").and_then(Value::as_u64).map(StoryId); + let (story, table, range) = if let Some(tid) = explicit_table { + let sid = explicit_story.or_else(|| find_table(doc, tid)).ok_or_else(|| bad(cmd, format!("no table {tid}")))?; + (sid, tid, None) + } else if let Some(c) = st.selection.cells { + (c.story, c.table, Some(c.range)) + } else if let Some(t) = st.selection.text { + match t.cell { + Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))), + None => { + let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?; + (t.story, tid, None) + } + } + } else { + return Err(bad(cmd, "place the insertion point in a table")); + }; + let t = doc.story(story).and_then(|x| x.tables.get(&table)).ok_or_else(|| bad(cmd, format!("no table {table}")))?; + let (nr, nc) = (t.nrows(), t.ncols()); + let mut range = range.unwrap_or(CellRange { r0: 0, c0: 0, r1: nr - 1, c1: nc - 1 }); + if let Some((a, b)) = pair(p, "rows", "row") { + range.r0 = a; + range.r1 = b; + } + if let Some((a, b)) = pair(p, "cols", "col") { + range.c0 = a; + range.c1 = b; + } + if range.r1 >= nr || range.c1 >= nc { + return Err(bad(cmd, format!("cell range out of bounds ({nr}×{nc} table)"))); + } + Ok(Tgt { story, table, range }) +} + +/// Edit the target table; afterwards selections pointing into it are clamped to its new shape. +pub(super) fn edit_table(s: &mut Session, g: &Tgt, cmd: &str, f: impl FnOnce(&mut Table) -> Result) -> Result { + let cmd = cmd.to_string(); + s.edit(|d, sel| { + let st = d.story_mut(g.story).ok_or_else(|| bad(&cmd, "no story"))?; + let t = st.table_mut(g.table).ok_or_else(|| bad(&cmd, "no table"))?; + let r = f(t)?; + clamp_selection(d, sel); + Ok(r) + }) +} + +/// Keep text/cell selections valid after the table changed shape (or disappeared). +pub(super) fn clamp_selection(d: &Document, sel: &mut Selection) { + if let Some(ts) = sel.cells { + match d.story(ts.story).and_then(|x| x.tables.get(&ts.table)) { + Some(t) => { + let (mr, mc) = (t.nrows() - 1, t.ncols() - 1); + let r = ts.range; + sel.cells = Some(TableSel { range: CellRange::new(r.r0.min(mr), r.c0.min(mc), r.r1.min(mr), r.c1.min(mc)), ..ts }); + } + None => sel.cells = None, + } + } + if let Some(t) = sel.text + && let Some(c) = t.cell + { + let story = d.story(t.story); + match story.and_then(|x| x.tables.get(&c.table)) { + Some(tb) => { + let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1)); + let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str()); + sel.text = Some(TextSel { + anchor: floor_char_boundary(text, t.anchor), + focus: floor_char_boundary(text, t.focus), + cell: Some(CellAddr { table: c.table, row: r, col }), + ..t + }); + } + None => { + let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor)); + sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); + } + } + } +} + +pub(super) fn count(p: &Value) -> usize { + p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize +} + +pub(super) fn color_param(p: &Value, key: &str) -> Option { + str_param(p, key).map(str::to_string) +} + +// ---------- create / convert ---------- + +/// Width of the text column at the caret (the new table spans it). +pub(super) fn column_width(s: &Session, t: &TextSel) -> f64 { + let Some(st) = s.active() else { return 300.0 }; + let specs = compose::frame_specs(&st.doc, t.story); + let spec = t.frame.and_then(|f| specs.iter().find(|x| x.id == f)).or(specs.first()); + spec.and_then(|f| f.columns().first().map(|c| c.width())).unwrap_or(300.0) +} + +/// The direction of the paragraph at `offset` in `story`, as composition resolves it (its style +/// chain, then local overrides). A table created there takes it. +pub(super) fn para_direction(d: &Document, story: StoryId, offset: usize) -> designcraft_doc::TextDirection { + d.story(story) + .and_then(|st| st.paras.get(st.para_at(offset))) + .map_or(designcraft_doc::TextDirection::LeftToRight, |pf| d.styles.resolve_para(pf).0.direction) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3a40844be..8e6038741 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -9,6 +9,30 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, bool_or, cmd, has_doc, has_text, has_text_or_frames, id_param, ok, point_param, str_param}; use crate::{Result, Session}; +mod asian; +mod caret; +mod edit; +mod format; +mod opentype; +mod story; + +use asian::{auto_tcy_cmd, grid_settings_cmd, kenten_cmd, ruby_cmd, shatai_cmd, warichu_cmd}; +pub(crate) use caret::story_of; +use caret::{move_caret, para_bounds, place, release, select_around, select_at, sentence_bounds}; +pub use caret::{next_char, next_word, prev_char, prev_word}; +pub(crate) use edit::delete_selection; +pub use edit::{PLACEHOLDER, quote_marks}; +use edit::{change_case, delete, delete_surrounding, fill_placeholder, insert}; +pub(crate) use format::{ + Target, TypingFormat, apply_char_attrs, effective_char_format, format_chars, format_paras, format_targets, format_targets_pub, + rename_typing_swatch, validate_char_fill, +}; +use format::{break_link, kern_pair, kern_step, link_style, para_toggle, pick_up_format, step_size, swap_fill_stroke}; +use opentype::{open_type, open_type_features, set_features}; +pub(crate) use story::set_story_direction; +use story::{selection_attrs, story_direction, story_options}; + +/// Build the text command registry: caret/selection movement, insertion/deletion, release, text and paragraph formatting, story queries, ruby and OpenType commands, with their parameters and enabled predicates. pub fn specs() -> Vec { vec![ cmd!(noundo "text.placeCaret", "Place Caret", [], None, "{frame, point: [x,y] (spread coords)} — converts empty frames to text frames", has_doc, |s, p| place(s, p, false)), @@ -72,6 +96,15 @@ pub fn specs() -> Vec { ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), + cmd!( + "text.deleteSurrounding", + "Delete Surrounding Text", + [], + None, + "{before?: int, after?: int, bytes?: bool, commit?: string} — removes the text around the selection (an input method's delete-surrounding), keeps the selection and its direction, and with `commit` types that text over it: one undo step", + has_text, + delete_surrounding + ), cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete), cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret), cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| { @@ -104,6 +137,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "story.options", + "Story Options", + [], + None, + "{story?, frame?: a text frame of the story (default: the stories of the text selection or the selected frames), opticalMargin?: bool — Optical Margin Alignment: punctuation and letter edges hang outside the frame in every paragraph of the story except those with ignoreOpticalMargin, opticalMarginSize?: pt (0.1–1296, default 12) — the type size the hang amounts are set for} → {stories}", + has_doc, + story_options + ), cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| { let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?); let text = super::text_param(p, "text"); @@ -182,7 +224,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, dropCapChars?, dropCapStyle?, dropCapAlignLeft?, dropCapScaleDescenders?, hyphenate?: bool (default true), composer?, opticalMargin?: bool (hang at each character's own size; a story with story.options opticalMargin overrides it), ignoreOpticalMargin?: bool (no hang in the paragraph), tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -374,7 +416,7 @@ pub fn specs() -> Vec { "Selection Attributes", [], None, - "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters)", + "{mixed?: bool} → resolved character/paragraph attributes at the text selection; with mixed, paraMixed lists the paragraph attributes that differ across the selected paragraphs; the selection's start as `selectedText` (one line, up to 60 characters); `storyOptions` {opticalMargin, opticalMarginSize} of the stories story.options would change (null where they differ)", has_doc, selection_attrs ), @@ -390,3580 +432,3 @@ pub fn specs() -> Vec { ), ] } - -pub(crate) fn story_of(s: &Session, p: &Value) -> Option { - let st = s.active()?; - if let Some(v) = p.get("story").and_then(Value::as_u64) { - return Some(StoryId(v)); - } - let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()); - if let Some(f) = frame { - return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story); - } - st.selection.text.map(|t| t.story) -} - -/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell). -fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option)> { - hit_byte_with(s, frame, pt, compose::hit) -} - -/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`] -/// (the character under the point). -fn hit_byte_with( - s: &Session, - frame: ItemId, - pt: Point, - hit: fn(&compose::ComposedStory, usize, Point) -> Option, -) -> Option<(StoryId, usize, Option)> { - let st = s.active()?; - let loc = st.doc.find(frame)?; - let it = st.doc.item_at(&loc)?; - let sid = it.text_frame()?.story; - let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it); - let inner = xf.inverse() * pt; - let cs = s.cache.get(&st.doc, sid, None); - let fi = cs.frames.iter().position(|f| f.frame == frame)?; - // Map path-text hits along the curve before choosing the character unit. - let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); - if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { - return Some((sid, b, Some(CellAddr { table, row, col }))); - } - if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) { - return Some((sid, b, Some(CellAddr::footnote(id)))); - } - let b = hit(&cs, fi, inner).unwrap_or(0); - Some((sid, b, None)) -} - -/// The Eyedropper with text selected: the formatting of the text under `point` in `frame` goes to -/// the selection, as InDesign's does (character style and overrides on the selected characters, -/// paragraph style and overrides on their paragraphs). -fn pick_up_format(s: &mut Session, p: &Value) -> Result { - const ID: &str = "type.pickUpFormat"; - let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; - let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; - let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; - let st = s.doc()?; - let sel = st.selection.text.ok_or_else(|| bad(ID, "select the text to format"))?; - let src = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let b = floor_char_boundary(&src.text, b); - // The character under the pointer (at the end of a line, the one before it). - let at = if b >= src.text.len() || src.text[b..].starts_with('\n') { b.saturating_sub(1) } else { b }; - let at = floor_char_boundary(&src.text, at); - let chars = src.char_format_at(at).clone(); - let para = src.paras.get(src.para_at(at)).cloned().ok_or(designcraft_doc::DocError::NoStory(sid))?; - let (a, e) = (sel.anchor.min(sel.focus), sel.anchor.max(sel.focus)); - s.typing_format = None; - s.edit(|d, _| { - let story = d.text_story_mut(sel.story, sel.cell).ok_or(designcraft_doc::DocError::NoStory(sel.story))?; - if e > a { - story.format_chars(a..e, |f| *f = chars.clone()); - } - story.format_paras(a..e, |f| { - f.style = para.style.clone(); - f.para = para.para.clone(); - }); - Ok(json!({"style": chars.style, "paragraphStyle": para.style})) - }) -} - -/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks). -fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { - const ID: &str = "text.selectAt"; - let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; - let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; - let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; - let (a, e) = unit_at(s, sid, cell, b, unit, ID)?; - s.text_drag = false; - s.typing_format = None; - let sel = TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }; - s.text_unit = Some(sel); - let st = s.doc_mut()?; - st.selection = Selection::text(sel); - // Explicit reselection invalidates the Find scope even when the range is unchanged. - st.bump_revision(); - Ok(json!({"story": sid.0, "anchor": a, "focus": e})) -} - -/// The `unit` (word, line, paragraph or story) around byte `b` of story `sid` (a cell's story -/// with `cell`), as the multiple clicks select it. -fn unit_at(s: &Session, sid: StoryId, cell: Option, b: usize, unit: &str, cmd: &str) -> Result<(usize, usize)> { - let st = s.doc()?; - let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let text = story.text.as_str(); - let b = floor_char_boundary(text, b); - Ok(match unit { - "word" => word_or_spaces_at(text, b), - "line" => { - let cs = s.cache.get(&st.doc, sid, None); - line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) - } - // The paragraph with its return (the last paragraph has none). - "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), - "story" => (0, text.len()), - other => return Err(bad(cmd, format!("unknown unit `{other}` (word, line, paragraph or story)"))), - }) -} - -/// The word at byte `i`; on spaces the run of spaces; on anything else that one character -/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: -/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. -fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { - use unicode_segmentation::UnicodeSegmentation; - let i = floor_char_boundary(s, i.min(s.len())); - // Clusters never span a paragraph return, so the paragraph is enough. - let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); - let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); - let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); - let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; - let kind = |c: &str| match c.chars().next() { - Some(c) if is_word(c) => 1, - Some(c) if c.is_whitespace() => 2, - _ => 0, - }; - let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); - let kd = g.get(k).map_or(0, |(_, c)| kind(c)); - if kd == 0 { - return span(k, k); - } - let mut a = k; - while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { - a -= 1; - } - let mut e = k; - while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { - e += 1; - } - span(a, e) -} - -/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. -fn text_composition(cs: std::sync::Arc, cell: Option) -> std::sync::Arc { - match cell { - Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), - Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), - None => cs, - } -} - -/// The composed line holding the character at byte `pos`. -fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { - let lines = || cs.frames.iter().flat_map(|f| f.lines.iter()); - lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos)) -} - -fn place(s: &mut Session, p: &Value, extend: bool) -> Result { - let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); - // Empty frames become text frames; a locked one stays as it is. - let st = s.doc()?; - let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked)); - let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); - if needs_convert && locked { - return Err(bad("text.placeCaret", "the frame is locked")); - } - if needs_convert { - s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?; - } - let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?; - // A drag after several clicks: whole units from the one first selected, in either direction. - if extend - && let Some(unit) = str_param(p, "unit") - && let Some(u) = s.text_unit.filter(|u| u.story == sid && u.cell == cell) - { - // The remembered unit may predate edits that shortened the text. - let text = s.doc()?.doc.text_story(sid, cell).map_or("", |t| t.text.as_str()); - let (ua, ue) = (floor_char_boundary(text, u.anchor.min(u.focus)), floor_char_boundary(text, u.anchor.max(u.focus))); - let (na, ne) = unit_at(s, sid, cell, b, unit, "text.extendTo")?; - let (anchor, focus) = if na < ua { (ue, na) } else { (ua, ne.max(ue)) }; - s.typing_format = None; - let st = s.doc_mut()?; - st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); - st.bump_revision(); - return Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})); - } - // Drag and drop: a press inside the selected text keeps it (to be dragged). - if !extend { - s.text_drag = s.doc()?.selection.text.is_some_and(|t| t.story == sid && t.cell == cell && !t.range().is_empty() && t.range().contains(&b)); - if s.text_drag { - return Ok(json!({"story": sid.0, "dragging": true})); - } - } else if s.text_drag { - return Ok(json!({"story": sid.0, "dragging": true})); - } - s.typing_format = None; - let st = s.doc_mut()?; - // Dragging from one cell into another selects cells. - if extend - && let (Some(t), Some(c)) = (st.selection.text, cell) - && let Some(a) = t.cell - && t.story == sid - && a.table == c.table - && (a.row, a.col) != (c.row, c.col) - { - let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col); - st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range }); - st.bump_revision(); - return Ok(json!({"story": sid.0, "cells": range})); - } - let t = match (extend, st.selection.text) { - (true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t }, - _ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell }, - }; - st.selection = Selection::text(t); - st.bump_revision(); - Ok(json!({"story": sid.0, "pos": b, "cell": cell})) -} - -fn release(s: &mut Session, p: &Value) -> Result { - if !std::mem::take(&mut s.text_drag) { - return ok(); - } - let frame = id_param(p, "frame").ok_or_else(|| bad("text.release", "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); - if !p.get("moved").and_then(Value::as_bool).unwrap_or(false) { - // A click in the selection: the caret goes there. - s.doc_mut()?.selection.text = None; - return place(s, &json!({"frame": frame.0, "point": [pt.x, pt.y]}), false); - } - let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); - let Some(t) = s.doc()?.selection.text else { return ok() }; - let Some((sid, b, cell)) = hit_byte(s, frame, pt) else { return ok() }; - let r = t.range(); - if sid != t.story || cell != t.cell || (r.contains(&b) && !copy) { - return ok(); - } - s.edit(|d, sel| { - let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let piece = st.extract(r.clone()); - let mut at = b; - if !copy { - st.delete(r.clone()); - if at > r.start { - at -= r.len().min(at - r.start); - } - } - let end = st.insert_story(at, &piece); - sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell }); - Ok(json!({"moved": piece.text.len(), "at": at})) - }) -} - -// Each command edits a copy-on-write Story snapshot. Reserving its known final text growth -// avoids retaining geometric String spare capacity in every independent undo snapshot. -// General Story insertion keeps its existing amortized-growth policy for mutable builders. -fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> Result<()> { - st.text.try_reserve_exact(additional).map_err(|e| crate::EngineError::Other(format!("could not reserve text insertion: {e}"))) -} - -fn insert(s: &mut Session, p: &Value) -> Result { - let text = super::text_param(p, "text"); - // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). - if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { - return super::table::step_cell(s, true); - } - let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); - let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; - let tracking = s.doc()?.doc.settings.track_changes; - let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); - let typing = typing_format(s).cloned(); - let result = s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; - let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let r = t.range(); - let mut r = r.start.min(st.len())..r.end.min(st.len()); - // Between a break character and its return is the end of that paragraph: typing there goes - // to the start of the next one. - if r.is_empty() - && st.text.get(r.start..).is_some_and(|t| t.starts_with('\n')) - && st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char) - { - r = r.start + 1..r.start + 1; - } - // Autocorrect: a word ended by a space or punctuation is looked up. - if let Some(list) = &autocorrect - && r.is_empty() - && text.chars().count() == 1 - && text.chars().all(|c| c.is_whitespace() || c.is_ascii_punctuation()) - { - let before = &st.text[..r.start]; - let ws = before.char_indices().rev().take_while(|(_, c)| c.is_alphabetic()).last().map(|(i, _)| i); - if let Some(ws) = ws { - let word = &before[ws..]; - if let Some((_, to)) = list.iter().find(|(from, _)| from.eq_ignore_ascii_case(word)) { - let to = if word.chars().next().is_some_and(char::is_uppercase) { - let mut c = to.chars(); - c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() - } else { - to.clone() - }; - st.replace(ws..r.start, &to); - r = ws + to.len()..ws + to.len(); - } - } - } - // A column, frame or page break ends its paragraph. - let mut text = if text.contains(designcraft_doc::is_break_char) { - designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next())) - } else { - text.clone() - }; - // Typing next to a table anchor starts a paragraph of its own. - let mut trail = 0; - if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() { - let pi = st.para_at(r.start); - let pr = st.para_ranges()[pi].clone(); - let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0); - if r.start <= anchor_at { - if !text.ends_with('\n') { - text.push('\n'); - trail = 1; - } - } else if !text.starts_with('\n') { - text.insert(0, '\n'); - } - } - if tracking && t.cell.is_none() { - // Track Changes: the replaced text is marked deleted, the typing inserted after it. - let at = super::changes::mark_deleted(st, r.clone()); - reserve_command_text(st, text.len())?; - let mut fmt = st.insert_format_at(at).clone(); - if let Some(f) = &typing { - apply_typing_format(&mut fmt, f); - } - fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted); - st.insert_with(at, &text, fmt); - let pos = at + text.len() - trail; - sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); - return Ok(json!({"pos": pos})); - } - let a = floor_char_boundary(&st.text, r.start.min(st.len())); - let b = floor_char_boundary(&st.text, r.end.min(st.len())).max(a); - reserve_command_text(st, text.len().saturating_sub(b - a))?; - st.replace(r.clone(), &text); - if let Some(f) = &typing - && !text.is_empty() - { - st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f)); - } - let pos = r.start + text.len() - trail; - sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); - Ok(json!({"pos": pos})) - }); - // A preview (IME marked text) leaves the typing format to the text typed in the end. - if result.is_ok() && s.active().is_none_or(|d| d.interaction.is_none()) { - s.typing_format = None; - } - result -} - -fn smart_quotes(s: &Session, text: &str) -> String { - if !text.contains(['"', '\'']) { - return text.to_string(); - } - let prev = s - .active() - .and_then(|st| { - let t = st.selection.text?; - let story = st.doc.text_story(t.story, t.cell)?; - story.text[..t.range().start.min(story.len())].chars().last() - }) - .unwrap_or(' '); - // The language at the insertion point picks the quote marks. - let language = s - .active() - .and_then(|st| { - let t = st.selection.text?; - let story = st.doc.text_story(t.story, t.cell)?; - let pos = t.range().start.min(story.len()); - let pi = story.para_at(pos).min(story.paras.len().saturating_sub(1)); - let base = st.doc.styles.resolve_para(story.paras.get(pi)?).1; - Some(st.doc.styles.resolve_char(&base, story.char_format_at(pos)).language) - }) - .unwrap_or_default(); - let [dq_open, dq_close, sq_open, sq_close] = quote_marks(&language); - let mut out = String::with_capacity(text.len()); - let mut last = prev; - for c in text.chars() { - let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}'); - let r = match c { - '"' => { - if open { - dq_open - } else { - dq_close - } - } - '\'' => { - if open { - sq_open - } else { - // An apostrophe inside a word stays an apostrophe. - if last.is_alphanumeric() { '\u{2019}' } else { sq_close } - } - } - c => c, - }; - out.push(r); - last = c; - } - out -} - -/// Typographer's quotes of a language (a name or locale code, see -/// [`designcraft_doc::language_tag`]): [double open, double close, single open, single close]. -pub fn quote_marks(language: &str) -> [char; 4] { - const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; - let Some(tag) = designcraft_doc::language_tag(language) else { return ENGLISH }; - // Swiss German, French and Italian use guillemets. - if tag.ends_with("-CH") { - return ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; - } - match tag { - "zh-Hans" => return ENGLISH, - "zh" | "zh-Hant" => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], - _ => {} - } - match designcraft_doc::language_subtag(tag) { - "de" | "cs" | "sk" | "bg" | "lt" => ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'], - "fr" | "ru" | "uk" | "no" | "nb" | "nn" | "el" => ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'], - "es" | "it" | "pt" | "ca" => ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'], - "nl" | "pl" | "ro" | "hu" | "hr" => ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'], - "sv" | "fi" => ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'], - "da" => ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'], - "ja" => ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], - _ => ENGLISH, - } -} - -pub(crate) fn delete_selection(s: &mut Session) -> Result { - let tracking = s.doc()?.doc.settings.track_changes; - s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; - let r = t.range(); - if let Some(st) = d.text_story_mut(t.story, t.cell) { - if tracking && t.cell.is_none() { - super::changes::mark_deleted(st, r.clone()); - } else { - st.delete(r.clone()); - } - } - sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); - ok() - }) -} - -fn delete(s: &mut Session, p: &Value) -> Result { - let forward = bool_or(p, "forward", false); - let word = bool_or(p, "word", false); - let tracking = s.doc()?.doc.settings.track_changes; - s.edit(|d, sel| { - let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; - let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let r = t.range(); - let r = if !r.is_empty() { - r - } else if forward { - let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) }; - r.start..end - } else { - let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) }; - start..r.start - }; - let r = with_break_pair(&st.text, r); - if tracking && t.cell.is_none() { - // Marked, not removed: the caret steps over the deleted text. - let at = super::changes::mark_deleted(st, r.clone()); - let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) }; - sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); - return ok(); - } - st.delete(r.clone()); - sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); - ok() - }) -} - -/// A break character and the return after it end one paragraph: deleting either deletes both. -fn with_break_pair(text: &str, r: std::ops::Range) -> std::ops::Range { - let Some(del) = text.get(r.clone()) else { return r }; - let mut chars = del.chars(); - match (chars.next(), chars.next()) { - (Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) { - Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end, - _ => r, - }, - (Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1, - _ => r, - } -} - -pub fn next_char(s: &str, i: usize) -> usize { - s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) -} -pub fn prev_char(s: &str, i: usize) -> usize { - s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0) -} -fn is_word(c: char) -> bool { - c.is_alphanumeric() || c == '\'' || c == '’' -} -pub fn next_word(s: &str, i: usize) -> usize { - let mut j = i; - let mut seen = false; - for (k, c) in s[i..].char_indices() { - if is_word(c) { - seen = true; - } else if seen { - return i + k; - } - j = i + k + c.len_utf8(); - } - j -} -pub fn prev_word(s: &str, i: usize) -> usize { - let mut seen = false; - for (k, c) in s[..i].char_indices().rev() { - if is_word(c) { - seen = true; - } else if seen { - return k + c.len_utf8(); - } - } - 0 -} -/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret -/// placement, a press inside the current selection is no start of a text drag here: it is the -/// third or later click of a row, which lands in the word or sentence just selected. -fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { - let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; - let pt = point_param(p, "point").unwrap_or(Point::ZERO); - let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; - let st = s.doc_mut()?; - let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); - let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); - s.text_drag = false; - s.typing_format = None; - let st = s.doc_mut()?; - st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); - st.bump_revision(); - Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) -} - -/// The paragraph around byte `i`, without its `\n`. -fn para_bounds(s: &str, i: usize) -> (usize, usize) { - let i = i.min(s.len()); - let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); - let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); - (a, b) -} - -/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and -/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full -/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. -fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { - let (pa, pb) = para_bounds(s, i); - let para = s.get(pa..pb).unwrap_or(""); - let mut start = 0; - let mut it = para.char_indices().peekable(); - while let Some((_, c)) = it.next() { - let cjk = matches!(c, '。' | '!' | '?'); - if !cjk && !matches!(c, '.' | '!' | '?' | '…') { - continue; - } - while it.peek().is_some_and(|&(_, c)| { - matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') - }) { - it.next(); - } - if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { - continue; - } - while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { - it.next(); - } - let end = it.peek().map_or(para.len(), |&(k, _)| k); - if pa + end > i || end == para.len() { - return (pa + start, pa + end); - } - start = end; - } - (pa + start, pb) -} - -/// Type ▸ Story Direction. -fn story_direction(s: &mut Session, p: &Value) -> Result { - let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; - let st = s.doc()?; - let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); - for id in &st.selection.items { - if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - } - s.edit(|d, _| { - set_story_direction(d, &stories, vertical); - Ok(json!({"stories": stories.len()})) - }) -} - -/// Set the direction of `stories` (all their frames turn together). -pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) { - for sid in stories { - if let Some(st) = d.story_mut(*sid) - && st.vertical != vertical - { - st.vertical = vertical; - st.rev = st.rev.wrapping_add(1); - } - } -} - -fn move_caret(s: &mut Session, p: &Value) -> Result { - let dir = str_param(p, "dir").unwrap_or("right").to_string(); - let extend = bool_or(p, "extend", false); - let word = bool_or(p, "word", false); - let st = s.doc()?; - let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?; - let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; - let text = story.text.clone(); - // In a cell, lines come from the cell's own composition. - let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell); - let pos = t.focus.min(text.len()); - // Vertical lines run top to bottom and follow each other right to left: Down and Up move along - // the line (Right and Left of horizontal text), Left and Right to the next and previous line. - let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical); - let dir = match (vertical_text, dir.as_str()) { - (true, "down") => "right", - (true, "up") => "left", - (true, "left") => "down", - (true, "right") => "up", - (_, d) => d, - }; - let collapse_to = |left: bool| if left { t.range().start } else { t.range().end }; - let new = match dir { - "left" if !extend && !t.is_caret() => collapse_to(true), - "right" if !extend && !t.is_caret() => collapse_to(false), - "left" | "right" => { - // Lines with right-to-left text move as drawn; at a line's edge, or by words, a - // right-to-left line's "left" is forward in the text. - let left = dir == "left"; - let visual = if word { None } else { compose::visual_step(&cs, pos, left) }; - match visual { - Some(p) => p, - None => { - let forward = left == compose::line_rtl(&cs, pos); - match (forward, word) { - (true, true) => next_word(&text, pos), - (true, false) => next_char(&text, pos), - (false, true) => prev_word(&text, pos), - (false, false) => prev_char(&text, pos), - } - } - } - } - "up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos), - "lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0), - "lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()), - "storyStart" => 0, - "storyEnd" => text.len(), - other => return Err(bad("text.move", format!("unknown dir `{other}`"))), - }; - s.typing_format = None; - let stm = s.doc_mut()?; - stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); - stm.bump_revision(); - Ok(json!({"pos": new})) -} - -fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { - cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end)) -} - -fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option { - let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?; - let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect(); - // Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it - // does not equal the parent line's baseline. - let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?; - let (_, l) = lines.get(cur)?; - if let Some(y) = compose::adjacent_row(l, x, caret_y, up) - && let Some(p) = compose::hit(cs, fi, Point::new(x, y)) - && p != pos - { - return Some(p); - } - // The next row: segments of the same line (beside a wrap object) share its baseline. - let other_row = |(i, t): &&(usize, &compose::Line)| *i != fi || (t.baseline - l.baseline).abs() > 1e-6; - let (tf, tl) = if up { lines.get(..cur)?.iter().rfind(other_row)? } else { lines.get(cur + 1..)?.iter().find(other_row)? }; - compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0)) -} - -/// Where a formatting command applies: a story (or a table cell's story) and a byte range. -#[derive(Clone, Debug)] -pub(crate) struct Target { - pub story: StoryId, - pub cell: Option, - pub range: std::ops::Range, -} - -/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the -/// text selection, or whole stories of the selected text frames. -pub(crate) fn format_targets(s: &Session) -> Vec { - let Some(st) = s.active() else { return vec![] }; - if let Some(ts) = st.selection.cells - && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) - { - let mut v = Vec::new(); - for (r, c) in t.selected_owners(ts.range) { - let len = t.cell(r, c).map_or(0, |x| x.text.len()); - v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); - } - return v; - } - if let Some(t) = st.selection.text { - return vec![Target { story: t.story, cell: t.cell, range: t.range() }]; - } - let mut v: Vec = Vec::new(); - for id in &st.selection.items { - if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) { - let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0); - if !v.iter().any(|t| t.story == tf.story) { - v.push(Target { story: tf.story, cell: None, range: 0..len }); - } - } - } - v -} - -/// Character formatting chosen at a bare caret in text: applied to what is typed there next. -#[derive(Clone, Debug)] -pub(crate) struct TypingFormat { - doc: u64, - at: TextSel, - attrs: CharAttrs, - /// Overrides removed (`null` in `type.char`). - cleared: Vec, -} - -/// The typing format for the active document's caret, if it was chosen at this caret. -fn typing_format(s: &Session) -> Option<&TypingFormat> { - let st = s.active()?; - let t = st.selection.text?; - s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range()) -} - -fn warichu_cmd(s: &mut Session, p: &Value) -> Result { - let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); - let on = match p.get("on").and_then(Value::as_bool) { - Some(v) => v, - None if has_opts => true, - None => !format_targets(s).first().is_some_and(|t| { - s.doc() - .ok() - .and_then(|d| d.doc.text_story(t.story, t.cell)) - .is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true)) - }), - }; - let mut body = serde_json::Map::new(); - body.insert("warichu".into(), json!(on)); - if on { - if let Some(v) = p.get("lines").and_then(Value::as_u64) { - body.insert("warichuLines".into(), json!(v.clamp(2, 16))); - } - if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) { - body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0))); - } - if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) { - body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0))); - } - if let Some(v) = p.get("align").and_then(Value::as_str) { - body.insert("warichuAlignment".into(), json!(v)); - } - if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) { - body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100))); - } - if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) { - body.insert("warichuCharsAfterBreak".into(), json!(v.min(100))); - } - } - format_chars(s, &Value::Object(body)) -} - -// Kenten, shatai, auto tate-chu-yoko and grid settings: validated settings for the CJK commands. - -/// Longest font, style or swatch name taken from parameters, in characters. -const NAME_MAX_CHARS: usize = 256; - -/// A number parameter: absent or `null` → `None`; anything but a finite number is an error. -fn finite_param(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => v.as_f64().filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))), - } -} - -/// A text parameter, cut to `max` characters: absent or `null` → `None`; not text is an error. -fn text_param(p: &Value, key: &str, cmd: &str, max: usize) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(t)) => Ok(Some(t.chars().take(max).collect())), - Some(_) => Err(bad(cmd, format!("`{key}` must be text"))), - } -} - -/// A boolean parameter: absent or `null` → `None`; not a boolean is an error. -fn bool_param(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))), - } -} - -/// An enumeration parameter checked against `T`, as its JSON value. -fn enum_param(p: &Value, key: &str, cmd: &str) -> Result> { - match p.get(key) { - None | Some(Value::Null) => Ok(None), - Some(v) => { - let t: T = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; - serde_json::to_value(t).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))) - } - } -} - -/// A ruby enum parameter, checked against its type. -fn ruby_enum(p: &Value, key: &str) -> Result> { - let Some(v) = p.get(key) else { return Ok(None) }; - serde_json::from_value::(v.clone()).map_err(|e| bad("type.ruby", format!("{key}: {e}")))?; - Ok(Some(v.clone())) -} - -/// A ruby number parameter clamped to `lo..=hi`, then divided by `div` (100 for percentages); -/// `null` gives `Value::Null` (automatic). -fn ruby_num(p: &Value, key: &str, lo: f64, hi: f64, div: f64) -> Result> { - match p.get(key) { - None => Ok(None), - Some(Value::Null) => Ok(Some(Value::Null)), - Some(v) => { - let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("type.ruby", format!("{key}: expected a finite number")))?; - Ok(Some(json!(x.clamp(lo, hi) / div))) - } - } -} - -/// Is `flag` on in the character format at the start of the first formatting target? -fn char_flag_on(s: &Session, flag: impl Fn(&CharAttrs) -> Option) -> bool { - format_targets(s).first().is_some_and(|t| { - s.doc() - .ok() - .and_then(|d| d.doc.text_story(t.story, t.cell)) - .is_some_and(|st| flag(&st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over) == Some(true)) - }) -} - -fn kenten_cmd(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::cjk_settings::{AdornmentOverprint, KENTEN_MARK_MAX_CHARS, KentenAlignment, KentenKind, KentenPosition}; - const CMD: &str = "type.kenten"; - let mut body = serde_json::Map::new(); - let mut set = |k: &str, v: Value| { - body.insert(k.to_string(), v); - }; - // Validate every setting first, so a bad one changes nothing. - if let Some(v) = enum_param::(p, "kind", CMD)? { - // A preset stores its mark as the character too, as IDML import does, so a custom - // character left from before isn't drawn in its place. - if let Some(mark) = serde_json::from_value::(v.clone()).ok().and_then(KentenKind::mark) { - set("kentenCharacter", json!(mark)); - } - set("kentenKind", v); - } - if let Some(c) = text_param(p, "character", CMD, KENTEN_MARK_MAX_CHARS)? { - set("kentenCharacter", json!(c.chars().filter(|c| !c.is_control()).collect::())); - } - for (key, attr) in [("font", "kentenFont"), ("fontStyle", "kentenFontStyle"), ("fill", "kentenFill"), ("stroke", "kentenStroke")] { - if let Some(t) = text_param(p, key, CMD, NAME_MAX_CHARS)? { - set(attr, json!(t)); - } - } - if let Some(v) = finite_param(p, "size", CMD)? { - set("kentenSize", json!(v.clamp(0.1, 1296.0))); - } - if let Some(v) = finite_param(p, "distance", CMD)? { - set("kentenDistance", json!(v.clamp(-1296.0, 1296.0))); - } - for (key, attr) in [("xScale", "kentenXScale"), ("yScale", "kentenYScale")] { - if let Some(v) = finite_param(p, key, CMD)? { - set(attr, json!(v.clamp(1.0, 1000.0) / 100.0)); - } - } - for (key, attr) in [("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint")] { - if let Some(v) = finite_param(p, key, CMD)? { - set(attr, json!(v.clamp(0.0, 100.0) / 100.0)); - } - } - if let Some(v) = finite_param(p, "strokeWeight", CMD)? { - set("kentenStrokeWeight", json!(v.clamp(0.0, 1000.0))); - } - if let Some(v) = enum_param::(p, "position", CMD)? { - set("kentenPosition", v); - } - if let Some(v) = enum_param::(p, "alignment", CMD)? { - set("kentenAlignment", v); - } - for (key, attr) in [("overprintFill", "kentenOverprintFill"), ("overprintStroke", "kentenOverprintStroke")] { - if let Some(v) = enum_param::(p, key, CMD)? { - set(attr, v); - } - } - // `null` returns a setting to the style's (size: half the text size by default). - for (key, attr) in - [("size", "kentenSize"), ("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint"), ("strokeWeight", "kentenStrokeWeight")] - { - if p.get(key).is_some_and(Value::is_null) { - set(attr, Value::Null); - } - } - let on = match bool_param(p, "on", CMD)? { - Some(v) => v, - None if !body.is_empty() => true, - None => !char_flag_on(s, |f| f.kenten), - }; - if !on { - // Off keeps the settings for the next time kenten are switched on. - body = serde_json::Map::new(); - } - body.insert("kenten".into(), json!(on)); - format_chars(s, &Value::Object(body)) -} - -fn shatai_cmd(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "type.shatai"; - let mut body = serde_json::Map::new(); - if let Some(v) = finite_param(p, "magnification", CMD)? { - body.insert("shataiMagnification".into(), json!(v.clamp(0.0, 90.0))); - } - if let Some(v) = finite_param(p, "angle", CMD)? { - body.insert("shataiAngle".into(), json!(v.rem_euclid(180.0))); - } - if let Some(v) = bool_param(p, "adjustRotation", CMD)? { - body.insert("shataiAdjustRotation".into(), json!(v)); - } - if let Some(v) = bool_param(p, "adjustTsume", CMD)? { - body.insert("shataiAdjustTsume".into(), json!(v)); - } - if body.is_empty() { - return Err(bad(CMD, "nothing to set: magnification, angle, adjustRotation or adjustTsume")); - } - format_chars(s, &Value::Object(body)) -} - -fn auto_tcy_cmd(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "type.autoTateChuYoko"; - let mut body = serde_json::Map::new(); - if let Some(v) = finite_param(p, "digits", CMD)? { - let max = f64::from(designcraft_compose::shape::AUTO_TCY_MAX); - body.insert("autoTcy".into(), json!(v.round().clamp(0.0, max) as u32)); - } - if let Some(v) = bool_param(p, "includeRoman", CMD)? { - body.insert("autoTcyIncludeRoman".into(), json!(v)); - } - if body.is_empty() { - return Err(bad(CMD, "nothing to set: digits or includeRoman")); - } - format_paras(s, &Value::Object(body)) -} - -fn grid_settings_cmd(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::cjk::CharacterAlignment as R; - const CMD: &str = "type.gridSettings"; - let mut body = serde_json::Map::new(); - let first_only = bool_param(p, "firstLineOnly", CMD)?; - match text_param(p, "alignment", CMD, 32)?.as_deref() { - None => { - if let Some(f) = first_only { - body.insert("gridAlign".into(), json!(if f { "firstLineOnly" } else { "allLines" })); - } - } - Some("none") => { - body.insert("gridAlign".into(), json!("none")); - } - Some(name) => { - let reference = match name { - "romanBaseline" | "baseline" => R::Baseline, - "emTop" => R::EmTop, - "emCenter" => R::EmCenter, - "emBottom" => R::EmBottom, - "icfTop" => R::IcfTop, - "icfBottom" => R::IcfBottom, - other => return Err(bad(CMD, format!("unknown alignment `{other}`"))), - }; - body.insert("gridAlign".into(), json!(if first_only == Some(true) { "firstLineOnly" } else { "allLines" })); - body.insert("gridReference".into(), json!(reference)); - } - } - if let Some(v) = finite_param(p, "gyoudori", CMD)? { - let max = 100.0; - body.insert("gridGyoudori".into(), json!(v.round().clamp(0.0, max) as u32)); - } - if let Some(v) = bool_param(p, "paragraphGyoudori", CMD)? { - body.insert("paragraphGyoudori".into(), json!(v)); - } - if body.is_empty() { - return Err(bad(CMD, "nothing to set: alignment, firstLineOnly, gyoudori or paragraphGyoudori")); - } - format_paras(s, &Value::Object(body)) -} - -/// Switch a paragraph attribute of the selected paragraphs: `on`, else the opposite of the -/// first one's. -fn para_toggle(s: &mut Session, p: &Value, cmd: &str, attr: &str, get: impl Fn(&designcraft_doc::ParaProps) -> bool) -> Result { - let on = match bool_param(p, "on", cmd)? { - Some(v) => v, - None => !format_targets(s).first().is_some_and(|t| { - s.doc().ok().is_some_and(|d| { - d.doc - .text_story(t.story, t.cell) - .and_then(|st| st.paras.get(st.para_at(t.range.start))) - .is_some_and(|pf| get(&d.doc.styles.resolve_para(pf).0)) - }) - }), - }; - let mut body = serde_json::Map::new(); - body.insert(attr.to_string(), json!(on)); - format_paras(s, &Value::Object(body)) -} - -fn ruby_text<'a>(p: &'a Value, key: &str) -> Result> { - let Some(v) = p.get(key) else { return Ok(None) }; - let t = v.as_str().ok_or_else(|| bad("type.ruby", format!("{key}: expected a string")))?; - if t.chars().count() > designcraft_doc::ruby::MAX_RUBY_CHARS { - return Err(bad("type.ruby", format!("{key}: longer than {} characters", designcraft_doc::ruby::MAX_RUBY_CHARS))); - } - Ok(Some(t)) -} - -fn ruby_cmd(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::cjk_settings::AdornmentOverprint; - use designcraft_doc::ruby::{RubyAlignment, RubyOverhang, RubyParentSpacing, RubyPosition, RubyType}; - let mut body = serde_json::Map::new(); - let mut put = |attr: &str, v: Option| { - if let Some(v) = v { - body.insert(attr.into(), v); - } - }; - put("ruby", ruby_text(p, "text")?.map(|t| json!(t))); - put("rubyType", ruby_enum::(p, "type")?); - put("rubyAlignment", ruby_enum::(p, "alignment")?); - put("rubyPosition", ruby_enum::(p, "position")?); - put("rubyOverhangAmount", ruby_enum::(p, "overhang")?); - put("rubyParentSpacing", ruby_enum::(p, "parentSpacing")?); - put("rubyOverprintFill", ruby_enum::(p, "overprintFill")?); - put("rubyOverprintStroke", ruby_enum::(p, "overprintStroke")?); - put("rubyXOffset", ruby_num(p, "xOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); - put("rubyYOffset", ruby_num(p, "yOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); - put("rubyFontSize", ruby_num(p, "size", 0.1, 1296.0, 1.0)?); - put("rubyXScale", ruby_num(p, "xScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); - put("rubyYScale", ruby_num(p, "yScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); - put("rubyScalingMin", ruby_num(p, "scalingPercent", 10.0, 100.0, 100.0)?.filter(|v| !v.is_null())); - put("rubyFillTint", ruby_num(p, "fillTint", 0.0, 100.0, 100.0)?); - put("rubyStrokeTint", ruby_num(p, "strokeTint", 0.0, 100.0, 100.0)?); - put("rubyStrokeWeight", ruby_num(p, "strokeWeight", 0.0, 800.0, 1.0)?); - put( - "rubyAutoTcyDigits", - ruby_num(p, "autoTcyDigits", 0.0, 9.0, 1.0)?.filter(|v| !v.is_null()).and_then(|v| v.as_f64()).map(|v| json!(v.round() as u32)), - ); - for (key, attr) in [ - ("openTypePro", "rubyOpenTypePro"), - ("autoTcyIncludeRoman", "rubyAutoTcyIncludeRoman"), - ("autoTcyAutoScale", "rubyAutoTcyAutoScale"), - ("autoAlign", "rubyAutoAlign"), - ("autoScaling", "rubyAutoScaling"), - ] { - if let Some(v) = p.get(key) { - let b = v.as_bool().ok_or_else(|| bad("type.ruby", format!("{key}: expected true or false")))?; - put(attr, Some(json!(b))); - } - } - put("rubyFont", ruby_text(p, "font")?.map(|t| json!(t))); - put("rubyFontStyle", ruby_text(p, "fontStyle")?.map(|t| json!(t))); - for (key, attr) in [("fill", "rubyFill"), ("stroke", "rubyStroke")] { - if let Some(name) = ruby_text(p, key)? { - let known = name.is_empty() || name == designcraft_doc::color::swatch::NONE || s.doc()?.doc.swatch(name).is_some(); - if !known { - return Err(bad("type.ruby", format!("{key}: no swatch `{name}`"))); - } - put(attr, Some(json!(name))); - } - } - format_chars(s, &Value::Object(body)) -} - -/// A character fill must name a swatch of the document. -pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { - validate_char_size(attrs, command_id)?; - match &attrs.fill { - Some(fill) => super::object::known_swatch(document, fill, command_id, "fill"), - None => Ok(()), - } -} - -/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's -/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative -/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page. -pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> { - match attrs.size { - Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => { - Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt"))) - } - _ => Ok(()), - } -} - -/// Type size limits (InDesign's, and the ones `type.ruby` already uses). -pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; -pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; - -/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that -/// line, and so the one a line-affecting attribute like leading can land on right away instead of -/// waiting for the next typed character (otherwise an empty line could never get its own leading). -fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { - let ranges = story.para_ranges(); - ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') -} - -pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { - let mut a = CharAttrs::default(); - if let Some(o) = attrs.as_object() { - for (k, v) in o { - a.set_json(k, v).map_err(|e| bad("type.char", e))?; - } - } - validate_char_fill(&s.doc()?.doc, &a, "type.char")?; - let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); - let targets = format_targets(s); - // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it - // also formats the mark right away, since the mark alone sets that line's height; typing there - // still needs the typing format, as new text takes the format of the run before the caret - // (the previous paragraph's mark). - if let [t] = targets.as_slice() - && t.range.is_empty() - && let Some(at) = s.doc()?.selection.text - && let Some(on_mark) = s.doc()?.doc.text_story(t.story, t.cell).filter(|st| !st.is_empty()).map(|st| empty_para_mark(st, t.range.start)) - { - let doc = s.doc()?.uid; - let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); - f.attrs.merge(&a); - f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k)); - f.cleared.extend(cleared.iter().cloned()); - if on_mark { - s.edit(|d, _| { - apply_char_attrs(d, &targets, &a, &cleared); - ok() - })?; - } - s.typing_format = Some(f); - return ok(); - } - s.edit(|d, _| { - apply_char_attrs(d, &targets, &a, &cleared); - ok() - }) -} - -/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the -/// character before it. With no such pair (story or paragraph start, a selection) this is -/// `type.char`. -fn kern_pair(s: &mut Session, p: &Value) -> Result { - const ID: &str = "type.kernPair"; - let kerning = p.get("kerning").ok_or_else(|| bad(ID, "missing `kerning`"))?; - let targets = format_targets(s); - if let [t] = targets.as_slice() - && t.range.is_empty() - && s.doc()?.selection.text.is_some() - && let Some(start) = s.doc()?.doc.text_story(t.story, t.cell).and_then(|st| pair_start(&st.text, t.range.start)) - { - let mut a = CharAttrs::default(); - a.set_json("kerning", kerning).map_err(|e| bad(ID, e))?; - let cleared = if kerning.is_null() { vec!["kerning".to_string()] } else { vec![] }; - let pair = Target { story: t.story, cell: t.cell, range: start..t.range.start }; - // Kerning chosen earlier for typing at this caret would hide the pair's value. - if typing_format(s).is_some() - && let Some(f) = &mut s.typing_format - { - f.attrs.kerning = None; - f.cleared.retain(|k| k != "kerning"); - } - return s.edit(|d, _| { - apply_char_attrs(d, &[pair], &a, &cleared); - ok() - }); - } - format_chars(s, &json!({ "kerning": kerning })) -} - -/// Byte offset of the character before `pos` when it starts a kerning pair with the character at -/// `pos`: both exist and neither is a paragraph or line break. -fn pair_start(text: &str, pos: usize) -> Option { - let is_text = |c: &char| !matches!(*c, '\n' | designcraft_doc::story::FORCED_LINE_BREAK); - text.get(pos..)?.chars().next().filter(is_text)?; - let c = text.get(..pos)?.chars().next_back().filter(is_text)?; - pos.checked_sub(c.len_utf8()) -} - -/// Character attributes `a` (and the overrides `cleared` removed) over each target's text. -pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { - for t in targets { - let r = &t.range; - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - if r.is_empty() { - if st.is_empty() { - // An empty story: its one (empty) run is the format typing takes. - if let Some(run) = st.chars.first_mut() { - run.format.over.merge(a); - st.rev = st.rev.wrapping_add(1); - } - } else if empty_para_mark(st, r.start) { - // An empty paragraph elsewhere in the story: its hidden paragraph mark is its only - // character, and the one that carries line-affecting attributes like leading. - st.format_chars(r.start..r.start + 1, |f| { - f.over.merge(a); - for k in cleared { - let _ = f.over.set_json(k, &Value::Null); - } - }); - } - continue; - } - st.format_chars(r.clone(), |f| { - f.over.merge(a); - for k in cleared { - let _ = f.over.set_json(k, &Value::Null); - } - }); - } -} - -pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { - let empty = serde_json::Map::new(); - let obj = attrs.as_object().unwrap_or(&empty); - // A rule object changes only the rule fields it names, so each paragraph gets its own attrs - // over its resolved rule; this pass validates them. - let mut a = ParaAttrs::default(); - for (k, v) in obj { - a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; - } - super::style::check_grep_styles("type.para", &a)?; - // `null` removes the override (back to the style's value). - let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); - let targets = format_targets(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - if let Some(st) = d.text_story_mut(t.story, t.cell) { - st.format_paras(t.range.clone(), |p| { - let (current, _) = styles.resolve_para(p); - let mut a = a.clone(); - for (k, v) in obj { - let _ = a.set_json_over(k, v, ¤t); - } - p.para.merge(&a); - for k in &cleared { - let _ = p.para.set_json(k, &Value::Null); - } - }); - } - } - ok() - }) -} - -/// Alt+Left/Right in text: kerning at a caret, tracking of a selection, by the Kerning/Tracking -/// increment. -fn kern_step(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::Kerning; - let inc = s.doc()?.doc.settings.kerning_increment; - let inc = if inc.is_finite() { inc.clamp(1.0, 1000.0) } else { 20.0 }; - let amount = match p.get("amount").and_then(Value::as_f64) { - Some(a) => a, - None => p.get("steps").and_then(Value::as_f64).unwrap_or(1.0) * inc, - }; - if !amount.is_finite() { - return Err(bad("type.kernStep", "`amount` must be a number")); - } - let amount = amount.clamp(-11000.0, 11000.0); - // Kerning and tracking range, in 1/1000 em. - let clamp = |v: f64| v.clamp(-1000.0, 10000.0); - let targets = format_targets(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - let mut out = json!({}); - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - if t.range.is_empty() { - // Manual kerning sits on the character before the caret (it applies after it). - let pos = t.range.start; - let Some(c) = st.text.get(..pos).and_then(|before| before.chars().next_back()) else { continue }; - let at = pos - c.len_utf8(); - let para = st.paras.get(st.para_at(at)).cloned().unwrap_or_default(); - let current = styles.resolve_char(&styles.resolve_para(¶).1, st.format_after(at)).kerning; - // The font's metrics or optical value isn't known here: those start from 0. - let v = clamp(if let Kerning::Manual(v) = current { v } else { 0.0 } + amount); - st.format_chars(at..pos, |f| f.over.kerning = Some(Kerning::Manual(v))); - out = json!({"kerning": v}); - } else { - for (i, pr) in st.para_ranges().into_iter().enumerate() { - let r = pr.start.max(t.range.start)..pr.end.min(t.range.end); - let Some(para) = st.paras.get(i).cloned() else { break }; - if r.is_empty() { - continue; - } - let pc = styles.resolve_para(¶).1; - st.format_chars(r, |f| { - let v = clamp(styles.resolve_char(&pc, f).tracking + amount); - f.over.tracking = Some(v); - out = json!({"tracking": v}); - }); - } - } - } - Ok(out) - }) -} - -fn step_size(s: &mut Session, delta: f64) -> Result { - let cur = selection_attrs(s, &json!({}))?; - let size = cur["chars"]["size"].as_f64().unwrap_or(12.0); - format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)})) -} - -fn break_link(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::{CharFormat, CharProps, ParaProps}; - let para = match p.get("kind").and_then(Value::as_str).unwrap_or("paragraph") { - "paragraph" => true, - "character" => false, - k => return Err(bad("style.breakLink", format!("unknown kind `{k}`"))), - }; - let targets = format_targets(s); - s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let r = t.range.clone(); - if para { - st.format_paras(r, |pf| { - let (pp, pc) = styles.resolve_para(pf); - pf.style = designcraft_doc::styles::NO_PARA_STYLE.into(); - pf.para = ParaProps::default().delta(&pp); - pf.chars = CharProps::default().delta(&pc); - }); - } else { - let ranges = st.para_ranges(); - for (pi, pr) in ranges.into_iter().enumerate() { - let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); - if a >= b { - continue; - } - let (_, pc) = styles.resolve_para(&st.paras[pi]); - st.format_chars(a..b, |f| { - let cp = styles.resolve_char(&pc, f); - *f = CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over: pc.delta(&cp) }; - }); - } - } - } - ok() - }) -} - -fn open_type(s: &mut Session, p: &Value) -> Result { - use designcraft_doc::otf; - let cur = selection_attrs(s, &json!({}))?; - let mut list: Vec = - cur["chars"]["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - if let Some(tag) = p.get("feature").and_then(Value::as_str) { - if tag.is_empty() || tag.len() > 4 || !tag.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(bad("type.openType", format!("bad feature tag `{tag}`"))); - } - let on = p.get("on").and_then(Value::as_bool).unwrap_or(!otf::is_on(&list, tag)); - otf::set(&mut list, tag, on); - } - if let Some(f) = p.get("figures").and_then(Value::as_str) - && !otf::set_figures(&mut list, f) - { - return Err(bad("type.openType", format!("unknown figure style `{f}`"))); - } - if let Some(sets) = p.get("stylisticSets").and_then(Value::as_array) { - let mask = sets.iter().filter_map(Value::as_u64).filter(|n| (1..=20).contains(n)).fold(0u32, |m, n| m | 1 << (n - 1)); - otf::set_stylistic_sets(&mut list, mask); - } - format_chars(s, &json!({"otfFeatures": list}))?; - Ok(json!({"features": list, "figures": otf::figures(&list)})) -} - -/// How much of the selected text `type.selectionAttrs` reports. -const SELECTED_TEXT_CHARS: usize = 60; - -fn selected_text_preview(chars: impl Iterator) -> String { - chars - .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) - .skip_while(|c| c.is_whitespace()) - .take(SELECTED_TEXT_CHARS) - .collect() -} - -fn selection_attrs(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let targets = format_targets(s); - let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) }; - let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; - let pi = story.para_at(r.start); - let pf = &story.paras[pi]; - let (pp, base) = st.doc.styles.resolve_para(pf); - let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; - let cp = st.doc.styles.resolve_char(&base, &cf); - let (style_para, style_chars) = st.doc.styles.resolve_para_style(&pf.style); - let mut character_base = base.clone(); - let character_style = st.doc.styles.char_style_attrs(&cf.style); - character_base.apply(&character_style); - let char_delta = character_base.delta(&cp); - let style_keys = character_style.keys(); - let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); - // The selected text (its start, on one line), for previews such as the font menu's. - let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); - let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, - "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), - "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, - "selectedText": selected.trim()}); - if bool_or(p, "mixed", false) { - out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); - } - Ok(out) -} - -/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`. -fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec { - let mut first: Option> = None; - let mut mixed = std::collections::BTreeSet::new(); - for t in targets { - let Some(story) = doc.text_story(t.story, t.cell) else { continue }; - let a = story.para_at(t.range.start); - let b = story.para_at(t.range.end.max(t.range.start)); - for pf in story.paras.get(a..=b).unwrap_or(&[]) { - let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue }; - match &first { - None => first = Some(v), - Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)), - } - } - } - mixed.into_iter().collect() -} - -/// The first target character, with any pending caret overrides. Keep character-style -/// inheritance separate from paragraph defaults for style creation from the selection. -pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { - let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let mut format = - if target.range.is_empty() { story.insert_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); - if let Some(pending) = typing_format(s) - && target.story == pending.at.story - && target.cell == pending.at.cell - && target.range == pending.at.range() - { - apply_typing_format(&mut format, pending); - } - Ok(format) -} - -/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. -pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { - let Some(uid) = s.active().map(|st| st.uid) else { return }; - if let Some(pending) = &mut s.typing_format - && pending.doc == uid - { - for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] - .into_iter() - .flatten() - { - if name == from { - *name = to.to_string(); - } - } - } -} - -fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { - CharAttrs { - fill: Some(cp.stroke.clone()), - fill_tint: Some(cp.stroke_tint), - stroke: Some(cp.fill.clone()), - stroke_tint: Some(cp.fill_tint), - ..Default::default() - } -} - -/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. -fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result { - const ID: &str = "type.swapFillStroke"; - let targets = format_targets(s); - if let [target] = targets.as_slice() - && target.range.is_empty() - && s.doc()?.selection.text.is_some() - { - let st = s.doc()?; - let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; - let (_, base) = st.doc.styles.resolve_para(pf); - let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); - return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); - } - let mut edits = Vec::new(); - let d = &s.doc()?.doc; - for target in targets { - let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; - if story.is_empty() { - let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; - let (_, base) = d.styles.resolve_para(pf); - let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); - validate_char_fill(d, &attrs, ID)?; - edits.push((target, attrs)); - continue; - } - let mut runs = story.runs().peekable(); - for (pi, para) in story.para_ranges().into_iter().enumerate() { - let start = para.start.max(target.range.start); - let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); - if start >= end { - continue; - } - let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; - let (_, base) = d.styles.resolve_para(pf); - while let Some((range, format)) = runs.peek() { - if range.end <= start { - runs.next(); - continue; - } - if range.start >= end { - break; - } - let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); - validate_char_fill(d, &attrs, ID)?; - edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); - if range.end > end { - break; - } - runs.next(); - } - } - } - s.edit(|d, _| { - for (target, attrs) in &edits { - apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); - } - ok() - }) -} - -fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { - cf.over.merge(&f.attrs); - for k in &f.cleared { - let _ = cf.over.set_json(k, &Value::Null); - } -} - -/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or -/// off as asked, else the opposite of the first selected character. -fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { - let id = if bold { "type.bold" } else { "type.italic" }; - let cur = selection_attrs(s, &json!({}))?; - let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string(); - let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string(); - // The document's own fonts count too. - let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); - let (weight, italic) = db.traits_of(&family, &style); - let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic }); - let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) }; - let targets = format_targets(s); - // A caret: the linked style of the format there, for the next typing. - if let [t] = targets.as_slice() - && t.range.is_empty() - { - let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?; - format_chars(s, &json!({"fontStyle": linked}))?; - return Ok(json!({"on": on, "fontStyle": linked})); - } - let mut changed = 0; - let r = s.edit(|d, _| { - let styles = d.styles.clone(); - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - for (pi, pr) in st.para_ranges().into_iter().enumerate() { - let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end)); - if a >= b { - continue; - } - let Some(pf) = st.paras.get(pi) else { continue }; - let (_, pc) = styles.resolve_para(pf); - st.format_chars(a..b, |f| { - let cp = styles.resolve_char(&pc, f); - if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic) - && linked != cp.font_style - { - f.over.font_style = Some(linked); - changed += 1; - } - }); - } - } - // No undo step for nothing. - if changed == 0 { - return Err(bad(id, "nothing to change")); - } - ok() - }); - r?; - Ok(json!({"on": on, "changed": changed})) -} - -/// One stretch of the selection: its length in bytes, the font it is set in and its features. -struct FeatureSegment { - len: usize, - font: (String, String), - applied: Vec, - ligatures: bool, -} - -/// The OpenType features of the fonts in the selection, with how much of the selection each one is -/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, -/// `some` or `all`. -fn open_type_features(s: &mut Session, _p: &Value) -> Result { - let st = s.doc()?; - let mut segments: Vec = Vec::new(); - for Target { story: sid, cell, range: r } in format_targets(s) { - let Some(story) = st.doc.text_story(sid, cell) else { continue }; - let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { - vec![(r.start, 1, story.format_after(r.start).clone())] - } else { - story - .runs() - .filter(|(run, _)| run.start < r.end && run.end > r.start) - .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) - .collect() - }; - for (pos, len, cf) in covered { - let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; - let (_, base) = st.doc.styles.resolve_para(para); - let cp = st.doc.styles.resolve_char(&base, &cf); - segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); - } - } - if segments.is_empty() { - let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); - let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); - segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); - } - let db = designcraft_fonts::FontDb::global(); - let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); - for seg in &segments { - fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); - } - let total: usize = segments.iter().map(|seg| seg.len).sum::().max(1); - let state = |covered: usize| match covered { - 0 => "none", - n if n >= total => "all", - _ => "some", - }; - // Bytes the feature is applied to, and bytes whose font offers it. - let measure = |tag: &str| { - let (mut applied, mut available) = (0, 0); - for seg in &segments { - // Standard ligatures are the `ligatures` attribute, which shaping reads, unless the - // feature list turns `liga` off. - let on = designcraft_doc::otf::is_on(&seg.applied, tag) && (tag != "liga" || seg.ligatures); - if on { - applied += seg.len; - } - if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { - available += seg.len; - } - } - (state(applied), state(available)) - }; - let tags: std::collections::BTreeSet = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); - let mut features = Vec::new(); - for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::().is_ok())) { - let (applied, available) = measure(tag); - features.push(json!({"tag": tag, "applied": applied, "available": available})); - } - // A stylistic set's name is shared when every font that offers it gives it the same name. - let mut sets = Vec::new(); - for set in 1..=20u32 { - let tag = format!("ss{set:02}"); - if !tags.contains(&tag) { - continue; - } - let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); - let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None }; - let (applied, available) = measure(&tag); - sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); - } - let used: Vec = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); - Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) -} - -/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they -/// are on. Each run keeps its other features, whichever font it is set in, and the change is one -/// undo step. -fn set_features(s: &mut Session, p: &Value) -> Result { - const ID: &str = "type.feature"; - let changes: Vec<(String, bool)> = p - .get("features") - .and_then(Value::as_object) - .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? - .iter() - .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) - .collect::>()?; - let targets: Vec = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); - if targets.is_empty() { - return Err(bad(ID, "select text")); - } - let styles = s.doc()?.doc.styles.clone(); - s.edit(|d, _| { - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let runs: Vec<(std::ops::Range, designcraft_doc::story::CharFormat)> = - st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); - for (run, format) in runs { - let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; - let (_, base) = styles.resolve_para(¶); - let mut list = styles.resolve_char(&base, &format).otf_features; - for (tag, on) in &changes { - designcraft_doc::otf::set(&mut list, tag, *on); - } - let range = run.start.max(t.range.start)..run.end.min(t.range.end); - st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); - } - } - ok() - }) -} - -/// Our own filler text (not Adobe's). -pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ -et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ -Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \ -dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \ -Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit."; - -fn fill_placeholder(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame)); - let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) { - Some(t) => t.story, - None => { - if let Some(f) = frame { - s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?; - } - story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? - } - }; - // Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text - // comes (so a paragraph can be styled on its own). - const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4]; - let mut text = String::new(); - let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); - let doc = s.doc()?.doc.clone(); - let mut d2 = (*doc).clone(); - let (mut paragraph, mut in_paragraph) = (0, 0); - for i in 0..400 { - text.push_str(sentences[i % sentences.len()]); - in_paragraph += 1; - if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] { - text.push('\n'); - (paragraph, in_paragraph) = (paragraph + 1, 0); - } else { - text.push(' '); - } - if i % 3 == 2 { - d2.set_story_text(sid, &text)?; - let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default()); - if cs.is_overset() { - // Trim to the last sentence that fits. - let cut = cs.overset_at.unwrap_or(text.len()).min(text.len()); - let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut); - text.truncate(keep); - break; - } - } - } - s.edit(|d, _| { - d.set_story_text(sid, text.trim_end())?; - ok() - }) -} - -pub(crate) fn format_targets_pub(s: &Session) -> Vec { - format_targets(s) -} - -/// The case-changed text of `src`, char by char (a char may map to several, e.g. ß → SS). -pub(crate) fn case_map(src: &str, case: &str) -> Vec { - let mut out = Vec::with_capacity(src.len()); - let mut prev = ' '; - let mut sentence_start = true; - for c in src.chars() { - let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}')); - let up = |c: char| c.to_uppercase().collect::(); - let low = |c: char| c.to_lowercase().collect::(); - // Title and sentence case capitalize only scripts with initial capitals (not Georgian). - let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) }; - let m = match case { - "upper" => up(c), - "lower" => low(c), - "title" => { - if word_start { - cap(c) - } else { - low(c) - } - } - _ => { - if sentence_start && c.is_alphanumeric() { - cap(c) - } else { - low(c) - } - } - }; - if c.is_alphanumeric() { - sentence_start = false; - } else if matches!(c, '.' | '!' | '?' | '\n' | '\u{2028}') { - sentence_start = true; - } - out.push(m); - prev = c; - } - out -} - -fn change_case(s: &mut Session, p: &Value) -> Result { - let case = str_param(p, "case").unwrap_or("upper").to_string(); - if !matches!(case.as_str(), "upper" | "lower" | "title" | "sentence") { - return Err(bad("type.changeCase", format!("unknown case `{case}`"))); - } - let targets = format_targets(s); - s.edit(|d, sel| { - for t in &targets { - let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; - let r = t.range.start.min(st.len())..t.range.end.min(st.len()); - let mapped = case_map(st.slice(r.clone()), &case); - // Replace run by run (from the end) so every run keeps its formatting. - let segs: Vec> = - st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); - let starts: Vec = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect(); - let mut delta: isize = 0; - for seg in segs.iter().rev() { - let old = st.slice(seg.clone()).to_string(); - let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect(); - if new != old { - delta += new.len() as isize - old.len() as isize; - st.replace(seg.clone(), &new); - } - } - if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) { - let end = (r.end as isize + delta).max(r.start as isize) as usize; - if ts.anchor <= ts.focus { - ts.focus = end; - } else { - ts.anchor = end; - } - } - } - Ok(Value::Null) - }) -} - -#[cfg(test)] -mod open_type_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn text_selection_stays_on_utf8_boundaries() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); - let sid = r["story"].clone(); - s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 4})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 2)); - s.execute("text.select", &json!({"story": sid, "anchor": 4})).unwrap(); - s.execute("text.delete", &json!({"forward": true})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(t.story).unwrap().text, "é🙂x"); - s.execute("text.select", &json!({"story": sid, "anchor": 2})).unwrap(); - s.execute("story.setText", &json!({"story": sid, "text": "漢字"})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 0)); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 3); - } - - #[test] - fn replacing_a_range_stays_on_utf8_boundaries() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); - let sid = r["story"].clone(); - let story = designcraft_doc::StoryId(sid.as_u64().unwrap()); - // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing. - for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] { - let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"})); - assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}"); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x"); - } - s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x"); - // A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone. - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - s.execute("story.replaceRange", &json!({"story": sid, "start": 0, "end": 2, "text": ""})).unwrap(); - let st = s.doc().unwrap(); - let t = st.selection.text.unwrap(); - let text = &st.doc.story(t.story).unwrap().text; - assert_eq!(text, "字🙂x"); - assert!(text.is_char_boundary(t.anchor) && text.is_char_boundary(t.focus), "caret {t:?} in {text:?}"); - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - } - - #[test] - fn selected_text_preview_stops_at_its_character_budget() { - let read = std::cell::Cell::new(0usize); - let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; - let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); - let preview = super::selected_text_preview(chars); - assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); - assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); - assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); - } - - #[test] - fn selection_attrs_report_the_selected_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s - .execute( - "frame.create", - &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue -What's new in typography this season, and why it matters to every designer"}), - ) - .unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); - s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); - let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); - assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); - s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); - } - - #[test] - fn open_type_toggles_reach_the_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Office 1/2 0"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 12})).unwrap(); - let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); - assert_eq!(r["features"], json!(["dlig"])); - let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); - assert_eq!(r["features"], json!([]), "toggles off"); - let r = s.execute("type.openType", &json!({"figures": "proportionalOldstyle", "stylisticSets": [1, 2]})).unwrap(); - assert_eq!(r["figures"], "proportionalOldstyle"); - let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); - let f: Vec<&str> = a["chars"]["otfFeatures"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); - assert!(f.contains(&"onum") && f.contains(&"ss02"), "{f:?}"); - assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err()); - } - - #[test] - fn break_link_to_paragraph_and_character_styles() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Head", "para": {"spaceBefore": 6}, "chars": {"size": 24}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Em", "chars": {"fontStyle": "Italic"}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Title here"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); - s.execute("style.character.apply", &json!({"name": "Em"})).unwrap(); - let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); - s.execute("style.breakLink", &json!({"kind": "character"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); - s.execute("style.breakLink", &json!({"kind": "paragraph"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); - let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); - assert_eq!(after["paragraphStyle"], "[No Paragraph Style]"); - assert_eq!(after["characterStyle"], "[None]"); - assert_eq!(after["chars"], before["chars"], "same look"); - assert_eq!(after["para"], before["para"]); - } - - /// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4). - #[test] - fn oldstyle_figures_change_the_glyphs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "2024"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let gids = |s: &Session| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - cs.frames[0].lines[0].glyphs.iter().map(|g| g.gid).collect::>() - }; - let lining = gids(&s); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); - s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap(); - assert_ne!(gids(&s), lining); - } - - /// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph - /// mark can still take a leading change right away: otherwise there'd be no way to adjust the - /// line height of a blank line at all. - #[test] - fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Para one\n\nPara two"; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - // The blank line between the two paragraphs: a collapsed caret, since it has no text. - let blank = text.find("\n\n").unwrap() + 1; - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap(); - let st = s.doc().unwrap().doc.story(sid).unwrap(); - // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so - // check just past the mark's own one-byte run, which the mark alone occupies. - assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); - let d = s.doc().unwrap().doc.clone(); - let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); - let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); - assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); - } - - /// Formatting chosen on a blank line is also what gets typed there, not the format of the - /// previous paragraph's mark that new text would otherwise take. - #[test] - fn text_typed_on_a_blank_line_takes_the_format_set_there() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Para one\n\nPara two"; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let blank = text.find("\n\n").unwrap() + 1; - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); - let st = s.doc().unwrap().doc.story(sid).unwrap(); - assert_eq!(st.text, "Para one\nMiddle\nPara two"); - for b in blank..blank + "Middle\n".len() { - assert_eq!(st.format_after(b).over.size, Some(30.0), "byte {b}"); - } - assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); - } - - /// A blank line keeps its format for typing after the caret has left it: text typed on it - /// takes its paragraph mark's format, with no pending typing format. - #[test] - fn a_blank_lines_mark_format_is_typed_after_the_caret_returns() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Para one\n\nPara two"; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let blank = text.find("\n\n").unwrap() + 1; - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); - assert!(s.typing_format.is_none()); - s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); - let st = s.doc().unwrap().doc.story(sid).unwrap(); - assert_eq!(st.text, "Para one\nMiddle\nPara two"); - assert_eq!(st.format_after(blank).over.size, Some(30.0)); - assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); - assert_eq!(st.format_after(2).over.size, None); - } -} - -#[cfg(test)] -mod nested_style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn nested_and_grep_styles_colour_the_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Lead", "chars": {"fill": "C=100 M=0 Y=0 K=0"}})).unwrap(); - s.execute("style.character.create", &json!({"name": "Num", "chars": {"fill": "C=0 M=100 Y=0 K=0"}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Opening words then 2026 here"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - s.execute( - "type.para", - &json!({"attrs": { - "nestedStyles": [{"style": "Lead", "through": true, "count": 2, "until": {"kind": "words"}}], - "grepStyles": [{"style": "Num", "pattern": "\\d+"}] - }}), - ) - .unwrap(); - let d = s.doc().unwrap().doc.clone(); - let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); - let text = &d.story(designcraft_doc::StoryId(sid)).unwrap().text; - let fill_at = |byte: usize| { - let g = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.byte == byte).unwrap(); - cs.styles[g.style as usize].fill.clone() - }; - assert_eq!(fill_at(0), "C=100 M=0 Y=0 K=0", "nested: first two words"); - assert_eq!(fill_at(text.find("words").unwrap()), "C=100 M=0 Y=0 K=0"); - assert_eq!(fill_at(text.find("then").unwrap()), "[Black]"); - assert_eq!(fill_at(text.find("2026").unwrap()), "C=0 M=100 Y=0 K=0", "GREP: digits"); - } -} - -#[cfg(test)] -mod multi_click_tests { - use serde_json::{Value, json}; - - use crate::Session; - - #[test] - fn moving_the_caret_drops_the_pending_typing_format() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; - let pending = |s: &mut Session| { - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - }; - // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the - // same caret is in the text's own format. - let moves: Vec> = vec![ - Box::new(|s| { - s.execute("text.move", &json!({"dir": "right"})).unwrap(); - s.execute("text.move", &json!({"dir": "left"})).unwrap(); - }), - Box::new(|s| { - s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - }), - Box::new(|s| { - s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); - }), - ]; - for m in &moves { - pending(&mut s); - m(&mut s); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - assert_eq!(size_at(&s, 6), None); - s.execute("edit.undo", &json!({})).unwrap(); - } - // Without a move it applies. - pending(&mut s); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - assert_eq!(size_at(&s, 6), Some(30.0)); - // Selecting a sentence or paragraph around a point drops it too. - for id in ["text.selectSentence", "text.selectParagraph"] { - pending(&mut s); - assert!(s.typing_format.is_some()); - s.execute(id, &json!({"frame": fid, "point": [80, 80]})).unwrap(); - assert!(s.typing_format.is_none(), "{id}"); - } - } - - #[test] - fn word_selection_keeps_grapheme_clusters_whole() { - use super::word_or_spaces_at as word; - // Decomposed "café": the combining acute belongs to the word, wherever the click lands. - let s = "un cafe\u{301} noir"; - let cafe = (3, 3 + "cafe\u{301}".len()); - assert_eq!(word(s, 4), cafe); - assert_eq!(word(s, 7), cafe, "on the combining mark"); - // Devanagari with a virama (U+094D) and a dependent vowel sign. - let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; - let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); - assert_eq!(word(s, 0), (0, w)); - assert_eq!(word(s, 9), (0, w), "on the virama"); - // An emoji ZWJ sequence and a variation selector stay one character. - let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; - let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); - assert_eq!(word(s, 2), e); - assert_eq!(word(s, 6), e, "on the joiner"); - assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); - // Empty paragraphs select nothing. - assert_eq!(word("a\n\nb", 2), (2, 2)); - } - - /// Issue #981: holding the button after a double, triple or quadruple click and moving extends - /// the selection a word, line or paragraph at a time, from the unit first selected, either way. - #[test] - fn a_drag_after_several_clicks_extends_the_selection_by_units() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Hello brave world.\nSecond paragraph here."; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - let at = |s: &Session, b: usize| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); - json!([g.x + g.adv * 0.75, l.baseline - 3.0]) - }; - let sel = |s: &Session| s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)).unwrap(); - let extend = |s: &mut Session, b: usize, unit: &str| { - let p = at(s, b); - s.execute("text.extendTo", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); - sel(s) - }; - // Double-click "brave", drag into "world.": whole words, anchored at the start of "brave". - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); - assert_eq!(sel(&s), (7, 12)); - assert_eq!(extend(&mut s, 14, "word"), (7, 18), "forward: through \"world\""); - assert_eq!(extend(&mut s, 9, "word"), (7, 12), "back inside the word: just the word"); - assert_eq!(extend(&mut s, 1, "word"), (12, 0), "backward: from \"Hello\" to the end of \"brave\""); - assert_eq!(&text[0..12], "Hello brave"); - // Triple-click the first line, drag into the second: both lines. - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "line"})).unwrap(); - assert_eq!(sel(&s), (0, 19)); - assert_eq!(extend(&mut s, 22, "line"), (0, text.len())); - // Without `unit` a drag still moves the focus a character at a time. - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); - s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14)})).unwrap(); - assert_eq!(sel(&s).0, 7); - assert!(sel(&s).1 > 12 && sel(&s).1 < 18, "{:?}", sel(&s)); - assert!(s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14), "unit": "sentence"})).is_err()); - // A unit remembered before the text was deleted stays inside what is left. - s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 14), "unit": "word"})).unwrap(); - s.execute("story.setText", &json!({"story": sid, "text": ""})).unwrap(); - s.execute("text.extendTo", &json!({"frame": fid, "point": [100, 90], "unit": "word"})).unwrap(); - assert_eq!(sel(&s), (0, 0)); - } - - #[test] - fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "Hello brave world.\nSecond paragraph here."; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - // Spread point over the right half of the glyph at byte `b`. - let at = |s: &Session, b: usize| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); - json!([g.x + g.adv * 0.75, l.baseline - 3.0]) - }; - let mut select = |unit: &str, b: usize| { - let p = at(&s, b); - let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); - (r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize) - }; - assert_eq!(select("word", 1), (0, 5), "inside a word"); - assert_eq!(select("word", 4), (0, 5), "its last letter"); - assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); - assert_eq!(select("word", 18), (18, 19), "punctuation alone"); - assert_eq!(select("line", 9), (0, 19)); - assert_eq!(select("paragraph", 9), (0, 20), "with its return"); - assert_eq!(select("paragraph", 22), (20, text.len())); - assert_eq!(select("story", 9), (0, text.len())); - // A second click inside the selected word selects again rather than starting a text drag. - s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap(); - let p = at(&s, 9); - s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 19)); - s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it"); - // The older word command agrees. - let p = at(&s, 8); - s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (7, 12)); - assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); - assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); - } -} - -#[cfg(test)] -mod style_link_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn bold_and_italic_follow_the_family_and_toggle() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - let style_at = |s: &mut Session, b: usize| { - s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap(); - s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string() - }; - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); - s.execute("type.italic", &json!({})).unwrap(); - assert_eq!(style_at(&mut s, 0), "Italic"); - // Bold over italic and plain text: each run takes its own linked style. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - assert_eq!(style_at(&mut s, 0), "Bold Italic"); - assert_eq!(style_at(&mut s, 7), "Bold"); - // Again: off, back to each run's regular weight. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - assert_eq!(style_at(&mut s, 0), "Italic"); - assert_eq!(style_at(&mut s, 7), "Regular"); - // Nothing to change: an error and no undo step. - s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - assert!(s.execute("type.bold", &json!({"on": false})).is_err()); - assert_eq!(s.doc().unwrap().history.undo.len(), steps); - // At a caret the style is for what is typed next. - let end = "plain words here".len(); - s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); - s.execute("type.bold", &json!({})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold"); - s.execute("text.insert", &json!({"text": " now"})).unwrap(); - assert_eq!(style_at(&mut s, end + 1), "Bold"); - assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged"); - // Moving the caret forgets it. - s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); - s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0); - } -} - -#[cfg(test)] -mod text_color_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn unnamed_colours_go_to_text_when_asked() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - s.execute("selection.set", &json!({"ids": [fid]})).unwrap(); - let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); - let before = fill_of_frame(&s); - let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap(); - let red = r["swatch"].as_str().unwrap().to_string(); - assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill"); - let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); - assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str())); - // A text selection takes it without asking. - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); - let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap(); - let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); - assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str()); - assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour"); - } -} - -#[cfg(test)] -mod drag_text_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn drag_selected_text_to_move_or_copy_it() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello brave world"})).unwrap(); - let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); - // Spread point of the glyph at byte `b`. - let at = |s: &Session, b: usize| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let l = &cs.frames[0].lines[0]; - let g = l.glyphs.iter().find(|g| g.byte == b).unwrap(); - json!([g.x + g.adv * 0.25, l.baseline - 3.0]) - }; - let end = |s: &Session| { - let d = &s.doc().unwrap().doc; - let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); - let l = &cs.frames[0].lines[0]; - json!([l.end_x + 2.0, l.baseline - 3.0]) - }; - s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 12})).unwrap(); - let r = s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 8)})).unwrap(); - assert_eq!(r["dragging"], true); - s.execute("text.extendTo", &json!({"frame": fid, "point": end(&s)})).unwrap(); - s.execute("text.release", &json!({"frame": fid, "point": end(&s), "moved": true})).unwrap(); - let text = |s: &Session| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); - assert_eq!(text(&s), "Hello worldbrave "); - // Alt-drag copies (the moved text is still selected). - s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 12)})).unwrap(); - s.execute("text.release", &json!({"frame": fid, "point": at(&s, 0), "moved": true, "copy": true})).unwrap(); - assert_eq!(text(&s), "brave Hello worldbrave "); - // A click inside the selection just places the caret. - s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 2)})).unwrap(); - s.execute("text.release", &json!({"frame": fid, "point": at(&s, 2), "moved": false})).unwrap(); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!(t.anchor, t.focus); - } -} - -#[cfg(test)] -mod nested_line_style_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn first_line_takes_the_nested_line_style() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("style.character.create", &json!({"name": "Lead", "chars": {"size": 18}})).unwrap(); - let text = "The opening line of this paragraph reads larger, and the lines after it return to the body size again."; - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("type.para", &json!({"nestedLineStyles": [{"style": "Lead", "lines": 1}]})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - let lines = &cs.frames[0].lines; - assert!(lines.len() >= 3); - let size_of = |l: usize| cs.styles[lines[l].glyphs.iter().find(|g| g.visible).unwrap().style as usize].size; - assert_eq!(size_of(0), 18.0, "the first line"); - assert!( - lines[0].glyphs.iter().filter(|g| g.visible && g.adv > 0.0).all(|g| cs.styles[g.style as usize].size == 18.0), - "the whole first line" - ); - assert_eq!(size_of(1), 12.0, "the next lines"); - let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - let st = back.stories.values().find(|st| st.text.starts_with("The opening")).unwrap(); - let nl = st.paras[0].para.nested_line_styles.clone().unwrap_or_default(); - assert_eq!((nl.len(), nl.first().map(|n| n.lines)), (1, Some(1)), "IDML AllNestedLineStyles"); - assert_eq!(nl[0].style, "Lead"); - } -} - -#[cfg(test)] -mod rule_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn a_partial_rule_edit_keeps_the_other_fields() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); - s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap(); - // Local: only the colour changes; the rest is inherited from the style. - s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap(); - let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); - let mut want = rule.clone(); - want["color"] = json!("[Registration]"); - assert_eq!(attrs["para"]["ruleBelow"], want); - assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched"); - // Style: only the weight changes. - s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap(); - let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap(); - let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap(); - let mut want = rule.clone(); - want["weight"] = json!(1.5); - assert_eq!(below, want); - assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field"); - } - - #[test] - fn rule_above_width_follows_column_or_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let mut widths = vec![]; - for column in [true, false] { - s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap(); - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - widths.push(cs.frames[0].decos[0].rect.width()); - } - assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}"); - assert!(widths[1] < 100.0, "text: {widths:?}"); - } -} - -#[cfg(test)] -mod border_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn paragraph_border_and_shading_follow_each_column() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let text = "word ".repeat(80); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 160], "content": "text", "text": text, "caret": false})).unwrap(); - s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("type.para", &json!({"borderOn": true, "borderWeights": [2, 1, 2, 1], "borderOffsets": [4, 4, 4, 4], "borderColor": "[Black]", "shadingOn": true, "shadingOffsets": [4, 4, 4, 4]})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - let decos = &cs.frames[0].decos; - let shading: Vec<_> = decos.iter().filter(|d| d.tint < 0.5).collect(); - assert_eq!(shading.len(), 2, "one shaded box per column"); - let edges = decos.len() - shading.len(); - assert_eq!(edges, 6, "top on the first part, bottom on the last, sides on both"); - let top = decos.iter().filter(|d| d.tint >= 0.5).map(|d| d.rect).fold(f64::MAX, |a, r| a.min(r.y0)); - let first_line = &cs.frames[0].lines[0]; - assert!((top - (first_line.baseline - first_line.ascent - 4.0 - 2.0)).abs() < 1e-6, "offset 4 + weight 2 above the text"); - let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); - let p = &back.stories.values().find(|st| st.text.starts_with("word")).unwrap().paras[0].para; - assert_eq!( - (p.border_on, p.border_weights, p.shading_offsets), - (Some(true), Some([2.0, 1.0, 2.0, 1.0]), Some([4.0; 4])), - "IDML paragraph border" - ); - } -} - -#[cfg(test)] -mod autocorrect_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn autocorrect_fixes_the_word_before_a_space() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - let text = |s: &Session| s.doc().unwrap().doc.stories[&sid].text.clone(); - // Off by default. - for c in ["t", "e", "h", " "] { - s.execute("text.insert", &json!({"text": c})).unwrap(); - } - assert_eq!(text(&s), "teh "); - s.execute("prefs.set", &json!({"autocorrect": true})).unwrap(); - for c in ["T", "e", "h", " ", "c", "a", "t", "."] { - s.execute("text.insert", &json!({"text": c})).unwrap(); - } - assert_eq!(text(&s), "teh The cat."); - // The caret ends after the inserted punctuation. - assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "teh The cat.".len()); - } -} - -#[cfg(test)] -mod break_tests { - use serde_json::json; - - use crate::Session; - - /// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one. - #[test] - fn inserted_break_ends_its_paragraph() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap(); - s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap(); - assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree"); - assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len()); - // At the end of a paragraph the break takes the place of its return. - let at = story(&s).text.find("\nThree").unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); - s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap(); - let st = story(&s); - assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree"); - assert_eq!(st.paras.len(), 3); - st.check().unwrap(); - // Typing after the break goes to the next paragraph; deleting the break or its return - // removes both. - let at = "One\u{e011}".len(); - s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); - s.execute("text.insert", &json!({"text": "x"})).unwrap(); - assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree"); - s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap(); - s.execute("text.delete", &json!({})).unwrap(); - assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree"); - s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap(); - s.execute("text.delete", &json!({"forward": true})).unwrap(); - assert_eq!(story(&s).text, "OnexTwoThree"); - } -} - -#[cfg(test)] -mod language_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn language_sets_quotes_spelling_and_hyphenation() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("type.char", &json!({"attrs": {"language": "German: 2006 Reform"}})).unwrap(); - s.execute("text.insert", &json!({"text": "\"Wort\" ist's"})).unwrap(); - let text = s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].text.clone(); - assert_eq!(text, "\u{201E}Wort\u{201C} ist\u{2019}s"); - // German words aren't English misspellings. - let st = s.execute("spelling.check", &json!({"story": sid})).unwrap(); - assert!(st.as_array().unwrap().is_empty(), "{st}"); - } - - #[test] - fn cjk_languages_have_their_quotes_and_no_english_rules() { - use super::quote_marks; - assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); - assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - // Other spellings of the language names take the same quotes. - assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); - assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); - assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); - for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] { - assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling"); - } - } - - #[test] - fn locale_coded_languages_take_their_quotes_and_rules() { - use super::quote_marks; - const GERMAN: [char; 4] = ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}']; - const DUTCH: [char; 4] = ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}']; - const GUILLEMETS: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; - const SPANISH: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}']; - const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; - const CORNERS: [char; 4] = ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']; - for (language, quotes, english) in [ - ("de_DE_2006", GERMAN, false), - ("German: 2006 Reform", GERMAN, false), - ("de_CH_2006", GUILLEMETS, false), - ("German: Swiss 2006 Reform", GUILLEMETS, false), - ("nl_NL_2005", DUTCH, false), - ("Dutch: 2005 Reform", DUTCH, false), - ("English: USA", ENGLISH, true), - ("English: UK", ENGLISH, true), - ("en_GB", ENGLISH, true), - ("Spanish: Castilian", SPANISH, false), - ("es_ES", SPANISH, false), - ("French", GUILLEMETS, false), - ("fr_FR", GUILLEMETS, false), - ("nb_NO", GUILLEMETS, false), - ("ja_JP", CORNERS, false), - ("[No Language]", ENGLISH, false), - ] { - assert_eq!(quote_marks(language), quotes, "{language}"); - assert_eq!(designcraft_compose::is_english(language), english, "{language}"); - } - } - - #[test] - fn idml_language_names_keep_their_spelling_and_pick_the_language() { - use std::io::Read; - // A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed). - designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap()); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); - s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap(); - let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap(); - let stories: String = (0..z.len()) - .map(|i| { - let mut f = z.by_index(i).unwrap(); - let mut x = String::new(); - if f.name().starts_with("Stories/") { - f.read_to_string(&mut x).unwrap(); - } - x - }) - .collect(); - assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}"); - // The name comes back as written and sets the text as Simplified Chinese. - let mut back = designcraft_idml::import_idml(&idml).unwrap(); - back.settings.glyph_fallback = true; - let st = back.stories.values().find(|st| st.text.contains('直')).unwrap(); - let (_, base) = back.styles.resolve_para(&st.paras[0]); - assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese"); - let cs = designcraft_compose::compose_story(&back, st.id, &Default::default()); - let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face; - assert_eq!(face.family, "Songti SC"); - } -} - -#[cfg(test)] -mod tcy_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn tate_chu_yoko_toggles_on_the_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - let at = "令和".len(); - s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap(); - let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko; - s.execute("type.tateChuYoko", &json!({})).unwrap(); - assert_eq!(get(&s), Some(true)); - s.execute("type.tateChuYoko", &json!({})).unwrap(); - assert_eq!(get(&s), Some(false)); - s.execute("type.tateChuYoko", &json!({"on": true})).unwrap(); - assert_eq!(get(&s), Some(true)); - } - - #[test] - fn ruby_and_kenten_on_the_selection() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); - s.execute("type.ruby", &json!({"text": "かんじ"})).unwrap(); - s.execute("type.kenten", &json!({})).unwrap(); - let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); - assert_eq!(f(&s).ruby.as_deref(), Some("かんじ")); - assert_eq!(f(&s).kenten, Some(true)); - s.execute("type.kenten", &json!({})).unwrap(); - s.execute("type.ruby", &json!({"text": ""})).unwrap(); - assert_eq!(f(&s).kenten, Some(false)); - assert_eq!(f(&s).ruby.as_deref(), Some("")); - } - - #[test] - fn ruby_options_are_checked_and_clamped() { - use designcraft_doc::ruby::{RubyAlignment, RubyPosition, RubyType}; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); - let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); - s.execute( - "type.ruby", - &json!({"text": "かん\u{3000}じ", "type": "perCharacter", "alignment": "oneAki", "position": "belowLeft", "size": 1e9, "xScale": -50, "scalingPercent": 5, "autoTcyDigits": 99, "fillTint": 250, "yOffset": -1e12, "fill": "[Black]"}), - ) - .unwrap(); - let a = f(&s); - assert_eq!( - (a.ruby.as_deref(), a.ruby_type, a.ruby_position), - (Some("かん\u{3000}じ"), Some(RubyType::PerCharacter), Some(RubyPosition::BelowLeft)) - ); - assert_eq!(a.ruby_font_size, Some(Some(1296.0))); - assert_eq!(a.ruby_x_scale, Some(0.01)); - assert_eq!(a.ruby_scaling_min, Some(0.1)); - assert_eq!(a.ruby_auto_tcy_digits, Some(9)); - assert_eq!(a.ruby_fill_tint, Some(Some(1.0))); - assert_eq!(a.ruby_y_offset, Some(-1000.0)); - // Options alone keep the reading. - s.execute("type.ruby", &json!({"alignment": "center"})).unwrap(); - assert_eq!((f(&s).ruby, f(&s).ruby_alignment), (a.ruby.clone(), Some(RubyAlignment::Center))); - // Refused, and nothing changes: unknown names, non-numbers, overlong text, unknown swatches. - let long = "あ".repeat(designcraft_doc::ruby::MAX_RUBY_CHARS + 1); - for p in [ - json!({"alignment": "sideways"}), - json!({"size": "big"}), - json!({"text": long}), - json!({"fill": "Nope"}), - json!({"autoAlign": 1}), - json!({"text": 5}), - ] { - assert!(s.execute("type.ruby", &p).is_err(), "{p}"); - } - assert_eq!(f(&s).ruby_alignment, Some(RubyAlignment::Center)); - assert_eq!(f(&s).ruby, a.ruby); - } - - #[test] - fn warichu_toggles_on_the_selection_and_undoes() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - let n = "注釈".len(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); - s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2})) - .unwrap(); - let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); - let a = f(&s); - assert_eq!(a.warichu, Some(true)); - assert_eq!(a.warichu_lines, Some(2)); - assert_eq!(a.warichu_size, Some(40.0)); - assert_eq!(a.warichu_line_spacing, Some(1.0)); - assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left)); - assert_eq!(a.warichu_chars_before_break, Some(2)); - assert_eq!(a.warichu_chars_after_break, Some(2)); - // The characters after the selection are not part of the note. - assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(f(&s).warichu, None); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(f(&s).warichu, Some(true)); - s.execute("type.warichu", &json!({})).unwrap(); - assert_eq!(f(&s).warichu, Some(false)); - } - - #[test] - fn warichu_up_and_down_move_between_the_rows() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap(); - s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; - assert!((4..8).contains(&down), "down enters the second row: {down}"); - let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize; - assert!(up < 4, "up returns to the first row: {up}"); - s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap(); - let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; - assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row"); - } -} - -#[cfg(test)] -mod bidi_caret_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn arrow_keys_in_a_right_to_left_paragraph() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "سلام عليكم"})).unwrap(); - let sid = r["story"].as_u64().unwrap(); - s.execute("type.para", &json!({"attrs": {"direction": "rightToLeft"}})).unwrap(); - s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); - // Left moves forward through right-to-left text, Right back. - let p = s.execute("text.move", &json!({"dir": "left"})).unwrap()["pos"].as_u64().unwrap(); - assert_eq!(p, 'س'.len_utf8() as u64); - let p = s.execute("text.move", &json!({"dir": "right"})).unwrap()["pos"].as_u64().unwrap(); - assert_eq!(p, 0); - // By words too. - let p = s.execute("text.move", &json!({"dir": "left", "word": true})).unwrap()["pos"].as_u64().unwrap(); - assert!(p >= "سلام".len() as u64, "{p}"); - } -} - -#[cfg(test)] -mod arabic_tests { - use super::*; - #[test] - fn arabic_story_direction_command_validates_and_supports_undo() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "abc"})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - let before = s.cache.get(&s.doc().unwrap().doc, sid, None); - s.execute("story.setDirection", &json!({"story": sid.0, "direction": "rightToLeft"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::RightToLeft); - let after = s.cache.get(&s.doc().unwrap().doc, sid, None); - assert!(!std::sync::Arc::ptr_eq(&before, &after)); - assert!(s.execute("story.setDirection", &json!({"story": sid.0, "direction": "unknown"})).is_err()); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight); - } -} - -#[cfg(test)] -mod korean_breaks_tests { - use serde_json::json; - - use crate::Session; - - #[test] - fn korean_character_breaks_are_a_paragraph_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true)); - s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap(); - assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true)); - } -} - -#[cfg(test)] -mod story_direction_tests { - use designcraft_doc::{ItemId, StoryId}; - use serde_json::json; - - use crate::Session; - - fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) { - let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap(); - (ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap())) - } - - /// Which frames of the story are composed vertically, in thread order. - fn composed(s: &Session, sid: StoryId) -> Vec { - s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect() - } - - #[test] - fn story_direction_turns_every_frame_of_the_story() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false); - let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); - s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); - assert_eq!(composed(&s, sid), [false, false]); - // From the second frame selected, through Type ▸ Story Direction. - s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); - s.execute("type.storyDirection", &json!({"vertical": true})).unwrap(); - assert_eq!(composed(&s, sid), [true, true]); - // From a caret in the text. - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("type.storyDirection", &json!({"vertical": false})).unwrap(); - assert_eq!(composed(&s, sid), [false, false]); - // The frame option sets the frame's story. - s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap(); - assert_eq!(composed(&s, sid), [true, true]); - // Undo puts it back. - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(composed(&s, sid), [false, false]); - } - - #[test] - fn a_frame_threaded_onto_a_vertical_story_turns_vertical() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true); - let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); - let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap(); - let c = ItemId(c["id"].as_u64().unwrap()); - s.edit(|d, _| { - d.thread(a, b)?; - Ok(d.thread(b, c)?) - }) - .unwrap(); - assert_eq!(composed(&s, sid), [true, true, true]); - } -} - -#[cfg(test)] -mod vertical_caret_tests { - use designcraft_doc::StoryId; - use serde_json::json; - - use crate::Session; - - /// A story set in three or more lines; returns its id. - fn story(s: &mut Session, vertical: bool) -> StoryId { - s.execute("file.new", &json!({})).unwrap(); - let text = "一二三四五六七八九十".repeat(8); - let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] }; - let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap(); - StoryId(r["story"].as_u64().unwrap()) - } - - fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize { - s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize - } - - /// (line index, position along the line) of the caret at `pos`. - fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) { - let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); - let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap(); - let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap(); - (line, x) - } - - #[test] - fn arrow_keys_follow_vertical_lines() { - let mut s = Session::new(); - let sid = story(&mut s, true); - assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3); - let c = '一'.len_utf8(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); - // Down and Up move along the line, a character at a time. - assert_eq!(mv(&mut s, "down", false, false), 4 * c); - assert_eq!(mv(&mut s, "up", false, false), 3 * c); - // Left moves to the next line (lines follow each other right to left), keeping the - // position along the line; Right comes back. - let (line, x) = place(&s, sid, 3 * c); - let next = mv(&mut s, "left", false, false); - let (nline, nx) = place(&s, sid, next); - assert_eq!(nline, line + 1); - assert!((nx - x).abs() < 0.5, "{x} {nx}"); - assert_eq!(mv(&mut s, "right", false, false), 3 * c); - // Shift extends. - assert_eq!(mv(&mut s, "down", true, false), 4 * c); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (3 * c, 4 * c)); - let p = mv(&mut s, "left", true, false); - let t = s.doc().unwrap().selection.text.unwrap(); - assert_eq!((t.anchor, t.focus), (3 * c, p)); - assert_eq!(place(&s, sid, p).0, line + 1); - // With a selection, Up collapses it to its start. - assert_eq!(mv(&mut s, "up", false, false), 3 * c); - // By words: Alt+Down is the vertical Alt+Right. - let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone(); - assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c)); - } - - #[test] - fn arrow_keys_in_horizontal_lines() { - let mut s = Session::new(); - let sid = story(&mut s, false); - let c = '一'.len_utf8(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); - assert_eq!(mv(&mut s, "right", false, false), 4 * c); - assert_eq!(mv(&mut s, "left", false, false), 3 * c); - let (line, x) = place(&s, sid, 3 * c); - let next = mv(&mut s, "down", false, false); - let (nline, nx) = place(&s, sid, next); - assert_eq!(nline, line + 1); - assert!((nx - x).abs() < 0.5, "{x} {nx}"); - assert_eq!(mv(&mut s, "up", false, false), 3 * c); - } -} - -#[cfg(test)] -mod story_query_contract_tests { - use serde_json::json; - #[test] - fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story")); - let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap(); - assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], ""); - assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], ""); - s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap(); - assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); - } - - #[test] - fn kenten_shatai_auto_tcy_and_grid_settings_take_checked_values() { - use designcraft_doc::cjk::CharacterAlignment; - use designcraft_doc::cjk_settings::{KentenKind, KentenPosition}; - let mut s = crate::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦書き12年", "vertical": true})).unwrap(); - let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": "縦書き".len()})).unwrap(); - let chars = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].char_format_at(1).over.clone(); - let para = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].paras[0].para.clone(); - // Any setting turns kenten on; values are clamped and text is capped. - let long = "★".repeat(10_000); - s.execute( - "type.kenten", - &json!({"kind": "custom", "character": long, "size": 1e300, "distance": -1e300, "xScale": 0, "fillTint": 250, "position": "belowLeft"}), - ) - .unwrap(); - let a = chars(&s); - assert_eq!((a.kenten, a.kenten_kind, a.kenten_position), (Some(true), Some(KentenKind::Custom), Some(KentenPosition::BelowLeft))); - assert_eq!(a.kenten_character.as_ref().map(|c| c.chars().count()), Some(designcraft_doc::cjk_settings::KENTEN_MARK_MAX_CHARS)); - assert_eq!( - (a.kenten_size, a.kenten_distance, a.kenten_x_scale, a.kenten_fill_tint), - (Some(Some(1296.0)), Some(-1296.0), Some(0.01), Some(Some(1.0))) - ); - // Bad values change nothing. - for bad in [json!({"kind": "sparkle"}), json!({"size": "big"}), json!({"character": 5}), json!({"on": "yes"}), json!({"overprintFill": 1})] { - assert!(s.execute("type.kenten", &bad).is_err(), "{bad}"); - } - assert_eq!(chars(&s), a); - s.execute("type.kenten", &json!({"on": false})).unwrap(); - assert_eq!((chars(&s).kenten, chars(&s).kenten_kind), (Some(false), Some(KentenKind::Custom)), "off keeps the settings"); - // Shatai. - s.execute("type.shatai", &json!({"magnification": 1e9, "angle": -30, "adjustRotation": true})).unwrap(); - let a = chars(&s); - assert_eq!((a.shatai_magnification, a.shatai_angle, a.shatai_adjust_rotation), (Some(90.0), Some(150.0), Some(true))); - assert!(s.execute("type.shatai", &json!({})).is_err()); - assert!(s.execute("type.shatai", &json!({"angle": [45]})).is_err()); - // Paragraph settings. - s.execute("type.autoTateChuYoko", &json!({"digits": 1e12, "includeRoman": true})).unwrap(); - assert_eq!((para(&s).auto_tcy, para(&s).auto_tcy_include_roman), (Some(designcraft_compose::shape::AUTO_TCY_MAX), Some(true))); - s.execute("type.gridSettings", &json!({"alignment": "emCenter", "firstLineOnly": true, "gyoudori": 1e12, "paragraphGyoudori": true})) - .unwrap(); - let p = para(&s); - assert_eq!((p.grid_align, p.grid_reference), (Some(designcraft_doc::GridAlign::FirstLineOnly), Some(CharacterAlignment::EmCenter))); - assert_eq!((p.grid_gyoudori, p.paragraph_gyoudori), (Some(100), Some(true))); - assert!(s.execute("type.gridSettings", &json!({"alignment": "middle"})).is_err()); - s.execute("type.gridSettings", &json!({"alignment": "none"})).unwrap(); - assert_eq!(para(&s).grid_align, Some(designcraft_doc::GridAlign::None)); - s.execute("type.rotateRoman", &json!({})).unwrap(); - assert_eq!(para(&s).rotate_roman, Some(true)); - s.execute("type.rotateRoman", &json!({})).unwrap(); - assert_eq!(para(&s).rotate_roman, Some(false)); - s.execute("type.romanWordBreak", &json!({"on": true})).unwrap(); - assert_eq!(para(&s).roman_word_break, Some(true)); - // The story still composes with every setting at its limit. - let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); - assert!(!cs.frames.is_empty()); - } -} - -#[cfg(test)] -mod sentence_selection_tests { - use super::{para_bounds, sentence_bounds}; - - fn sentence(s: &str, at: &str) -> String { - let i = s.find(at).unwrap(); - let (a, b) = sentence_bounds(s, i); - s[a..b].to_string() - } - - #[test] - fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { - let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; - assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); - assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); - assert_eq!(sentence(s, "Twice"), "(Twice!) "); - assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); - assert_eq!(sentence(s, "fine"), "fine."); - assert_eq!(sentence(s, "one"), "Next one. "); - assert_eq!(sentence(s, "Last"), "Last"); - // The caret at the very end of a paragraph is in its last sentence. - let end = s.find('\n').unwrap(); - assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); - } - - #[test] - fn cjk_full_stops_end_sentences_without_a_space() { - let s = "これは文です。次の文!最後"; - assert_eq!(sentence(s, "次"), "次の文!"); - assert_eq!(sentence(s, "最後"), "最後"); - } - - #[test] - fn paragraphs_stop_at_returns() { - let s = "One.\nTwo two.\n"; - assert_eq!(para_bounds(s, 6), (5, 13)); - assert_eq!(para_bounds(s, 0), (0, 4)); - assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); - } -} - -#[cfg(test)] -mod reserve_tests { - use super::*; - - #[test] - fn failed_reservation_rolls_back_the_edit_even_after_local_text_was_removed() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let result = s.execute("frame.create", &json!({"rect":[36,36,300,300],"content":"text","text":"Keep this text."})).unwrap(); - let sid = StoryId(result["story"].as_u64().unwrap()); - s.execute("text.select", &json!({"story":sid.0,"anchor":1,"focus":4})).unwrap(); - let before = s.doc().unwrap().clone(); - assert!(before.selection.text.is_some_and(|t| !t.range().is_empty())); - let journal = s.journal.clone(); - let error = s - .edit(|d, sel| { - let st = d.story_mut(sid).unwrap(); - st.format_chars(5..9, |f| f.over.change = Some(designcraft_doc::ChangeMark::Inserted)); - let at = super::super::changes::mark_deleted(st, 0..9); - assert_eq!(at, 5); - assert_eq!(st.text, "Keep text."); - assert_eq!(st.format_after(0).over.change, Some(designcraft_doc::ChangeMark::Deleted)); - sel.text = None; - reserve_command_text(st, usize::MAX)?; - ok() - }) - .unwrap_err(); - assert!(error.to_string().contains("could not reserve text insertion")); - let after = s.doc().unwrap(); - assert!(std::sync::Arc::ptr_eq(&after.doc, &before.doc)); - assert_eq!(after.selection, before.selection); - assert_eq!(after.revision, before.revision); - assert_eq!(after.history.undo.len(), before.history.undo.len()); - assert_eq!(after.history.redo.len(), before.history.redo.len()); - assert_eq!(s.journal, journal); - } -} - -#[cfg(test)] -mod open_type_features_tests { - use serde_json::{Value, json}; - - use crate::Session; - - fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { - v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) - } - - #[test] - fn the_selected_fonts_features_are_listed_with_their_state() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); - assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); - assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); - } - - #[test] - fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); - let story = r["story"].clone(); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); - s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); - s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); - s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); - } - - #[test] - fn standard_ligatures_follow_the_ligatures_attribute() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "office"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 6})).unwrap(); - s.execute("type.char", &json!({"attrs": {"ligatures": false}})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "liga")["applied"], "none", "Character › Ligatures off: {v}"); - s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); - s.execute("type.feature", &json!({"features": {"liga": false}})).unwrap(); - let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); - assert_eq!(feature(&v, "liga")["applied"], "none", "-liga in the feature list: {v}"); - } -} - -#[cfg(test)] -mod gradient_text_tests { - use serde_json::json; - - /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns - /// `x0..x1`. - fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { - let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); - for x in x0..x1 { - for y in 0..img.height { - let p = img.pixel(x, y); - if p[1] < 100 { - (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); - } - } - } - assert!(n > 50.0, "no ink in {x0}..{x1}"); - (r / n, b / n) - } - - /// Red ink at the frame's left, blue at its right. - fn check(img: &designcraft_render::Rendered, what: &str) { - let (lr, lb) = ink(img, 0, 60); - let (rr, rb) = ink(img, 200, 300); - assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); - assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); - } - - #[test] - fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); - // A gradient swatch (an off-page object's unnamed one), red → blue. - let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); - s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) - .unwrap(); - let id = designcraft_doc::ItemId(shape.as_u64().unwrap()); - let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); - s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let mut rr = designcraft_render::Renderer::new(); - check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); - // The PDF, placed back and drawn: the same gradient. - let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300})) - .unwrap(); - let d = p.doc().unwrap().doc.clone(); - check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); - } - - /// The columns the red → blue ink spans (black table edges left out). - fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { - let cols: Vec = (0..img.width) - .filter(|&x| { - (0..img.height).any(|y| { - let p = img.pixel(x, y); - p[1] < 100 && p[0].max(p[2]) > 100 - }) - }) - .collect(); - (*cols.first().unwrap(), *cols.last().unwrap()) - } - - /// Mean red and blue of the red → blue ink in columns `x0..x1`. - fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { - let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); - for x in x0..x1 { - for y in 0..img.height { - let p = img.pixel(x, y); - if p[1] < 100 && p[0].max(p[2]) > 100 { - (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); - } - } - } - assert!(n > 20.0, "no ink in {x0}..{x1}"); - (r / n, b / n) - } - - #[test] - fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { - let mut s = crate::Session::new(); - s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); - let g = s - .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) - .unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); - s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); - // A third of the frame's width: the cell's text, red → blue. - let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); - let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; - let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); - let text = &mut t.cell_mut(0, 0).unwrap().text; - text.insert(0, "HHHH"); - text.format_chars(0..4, |f| { - f.over.fill = Some(g.clone()); - f.over.size = Some(24.0); - }); - let mut rr = designcraft_render::Renderer::new(); - let cache = designcraft_compose::Cache::new(); - let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); - let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); - let mut p = crate::Session::new(); - p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); - p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})) - .unwrap(); - let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); - let (x0, x1) = ink_span(&screen); - assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); - let q = (x1 - x0) / 4; - for (what, img) in [("screen", &screen), ("pdf", &placed)] { - let (lr, lb) = coloured_ink(img, x0, x0 + q); - let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); - assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); - assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); - } - } -} - -#[cfg(test)] -mod placeholder_tests { - use serde_json::json; - - use crate::Session; - - /// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one - /// paragraph, and the filler ran sentences together: "interit.Ovid"). - #[test] - fn placeholder_text_comes_in_paragraphs() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap(); - s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap(); - let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); - let text = st["text"].as_str().unwrap(); - let paragraphs: Vec<&str> = text.split('\n').collect(); - assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len()); - for p in ¶graphs { - assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}"); - } - assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}"); - assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]); - assert!(text.len() > 1500, "the frame is filled: {}", text.len()); - } -} - -#[cfg(test)] -mod kern_pair_tests { - use designcraft_doc::{Kerning, StoryId}; - use serde_json::json; - - use crate::Session; - - fn avatar() -> (Session, StoryId) { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AVATAR"})).unwrap(); - (s, StoryId(r["story"].as_u64().unwrap())) - } - - fn kern_at(s: &Session, story: StoryId, pos: usize) -> Option { - s.doc().unwrap().doc.story(story).unwrap().format_after(pos).over.kerning - } - - #[test] - fn kerning_at_a_caret_kerns_the_pair_in_one_undo_step() { - let (mut s, sid) = avatar(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); - assert_eq!(kern_at(&s, sid, 0), Some(Kerning::Manual(-80.0)), "A, before the caret, spaces the A|V pair"); - assert_eq!(kern_at(&s, sid, 1), None); - assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); - let shown = s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["kerning"].clone(); - assert_eq!(shown, json!({"kind": "manual", "value": -80.0}), "the caret shows the pair's kerning"); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(kern_at(&s, sid, 0), None); - } - - #[test] - fn kerning_a_selection_or_at_the_story_start_works_like_type_char() { - let (mut s, sid) = avatar(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 4})).unwrap(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": 20}})).unwrap(); - let kerned: Vec<_> = (0..6).map(|i| kern_at(&s, sid, i)).collect(); - let m = Some(Kerning::Manual(20.0)); - assert_eq!(kerned, [None, None, m, m, None, None]); - // No character before the caret: the value is for the next typing. - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "none"}})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps); - assert_eq!(kern_at(&s, sid, 0), None); - s.execute("text.insert", &json!({"text": "X"})).unwrap(); - assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); - } - - #[test] - fn kerning_at_the_end_of_a_paragraph_or_story_leaves_the_text_alone() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AV\nTA"})).unwrap(); - let sid = StoryId(r["story"].as_u64().unwrap()); - for caret in [2, 5] { - s.execute("text.select", &json!({"story": sid.0, "anchor": caret, "focus": caret})).unwrap(); - let steps = s.doc().unwrap().history.undo.len(); - s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no pair at {caret}: nothing is edited"); - assert_eq!((kern_at(&s, sid, 1), kern_at(&s, sid, 4)), (None, None), "caret {caret}"); - } - } -} - -#[cfg(test)] -mod pick_up_format_tests { - use designcraft_doc::ItemId; - use serde_json::json; - - use crate::Session; - - /// Typefool's video (12:04): select body text, Eyedropper, click the third header: the body - /// text takes the header's font, size and paragraph settings. DesignCraft copied the frame's - /// fill and stroke instead. - #[test] - fn the_eyedropper_copies_text_formatting_to_selected_text() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let head = s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Innovative Design"})).unwrap(); - s.execute("text.select", &json!({"story": head["story"], "anchor": 0, "focus": 17})).unwrap(); - s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold", "size": 18, "tracking": 20}})).unwrap(); - s.execute("type.para", &json!({"attrs": {"align": "center", "spaceAfter": 6}})).unwrap(); - let body = s.execute("frame.create", &json!({"rect": [36, 200, 400, 300], "content": "text", "text": "Body copy here.\nSecond."})).unwrap(); - s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); - // Click the header's middle (the frame's text starts at its top left). - let r = s.execute("type.pickUpFormat", &json!({"frame": head["id"], "point": [100, 50]})).unwrap(); - assert!(r.is_object(), "{r}"); - s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); - let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); - assert_eq!((&a["chars"]["fontStyle"], &a["chars"]["size"], &a["chars"]["tracking"]), (&json!("Bold"), &json!(18.0), &json!(20.0)), "{a}"); - assert_eq!((&a["para"]["align"], &a["para"]["spaceAfter"]), (&json!("center"), &json!(6.0))); - // Only the selected characters change; the second paragraph keeps its own. - s.execute("text.select", &json!({"story": body["story"], "anchor": 6, "focus": 10})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], json!(12.0)); - s.execute("text.select", &json!({"story": body["story"], "anchor": 17, "focus": 20})).unwrap(); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], json!("left")); - // Not a text frame, or nothing selected: refused. - let rect = s.execute("frame.create", &json!({"rect": [500, 500, 520, 520]})).unwrap()["id"].as_u64().unwrap(); - assert!(s.execute("type.pickUpFormat", &json!({"frame": rect, "point": [510, 510]})).is_err()); - let _ = ItemId(rect); - } -} - -#[cfg(test)] -mod size_bounds_tests { - use serde_json::json; - - use crate::Session; - - /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative - /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times - /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt. - #[test] - fn a_type_size_outside_the_range_is_refused() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 1})).unwrap(); - let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); - let select = |s: &mut Session| { - s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); - }; - for size in [0.0, -12.0, 0.05, 1297.0, 1e9] { - select(&mut s); - let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string(); - assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}"); - } - // The sizes in range still apply, including the ends. - for size in [0.1, 12.0, 1296.0] { - select(&mut s); - s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}")); - assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size)); - } - // And the styles that carry character attributes are checked the same way. - for (command, params) in [ - ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})), - ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})), - ] { - let e = s.execute(command, ¶ms).expect_err("refused").to_string(); - assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); - } - } -} diff --git a/crates/engine/src/cmd/text/asian.rs b/crates/engine/src/cmd/text/asian.rs new file mode 100644 index 000000000..8192bcad4 --- /dev/null +++ b/crates/engine/src/cmd/text/asian.rs @@ -0,0 +1,451 @@ +use super::format::{char_flag_on, format_chars, format_paras, format_targets}; +use super::{Result, Session, Value, bad, json}; + +pub(super) fn warichu_cmd(s: &mut Session, p: &Value) -> Result { + let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some()); + let on = match p.get("on").and_then(Value::as_bool) { + Some(v) => v, + None if has_opts => true, + None => !format_targets(s).first().is_some_and(|t| { + s.doc() + .ok() + .and_then(|d| d.doc.text_story(t.story, t.cell)) + .is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true)) + }), + }; + let mut body = serde_json::Map::new(); + body.insert("warichu".into(), json!(on)); + if on { + if let Some(v) = p.get("lines").and_then(Value::as_u64) { + body.insert("warichuLines".into(), json!(v.clamp(2, 16))); + } + if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) { + body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0))); + } + if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) { + body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0))); + } + if let Some(v) = p.get("align").and_then(Value::as_str) { + body.insert("warichuAlignment".into(), json!(v)); + } + if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) { + body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100))); + } + if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) { + body.insert("warichuCharsAfterBreak".into(), json!(v.min(100))); + } + } + format_chars(s, &Value::Object(body)) +} + +// Kenten, shatai, auto tate-chu-yoko and grid settings: validated settings for the CJK commands. + +/// Longest font, style or swatch name taken from parameters, in characters. +const NAME_MAX_CHARS: usize = 256; + +/// A number parameter: absent or `null` → `None`; anything but a finite number is an error. +fn finite_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().filter(|v| v.is_finite()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite number"))), + } +} + +/// A text parameter, cut to `max` characters: absent or `null` → `None`; not text is an error. +fn text_param(p: &Value, key: &str, cmd: &str, max: usize) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(t)) => Ok(Some(t.chars().take(max).collect())), + Some(_) => Err(bad(cmd, format!("`{key}` must be text"))), + } +} + +/// A boolean parameter: absent or `null` → `None`; not a boolean is an error. +pub(super) fn bool_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_bool().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))), + } +} + +/// An enumeration parameter checked against `T`, as its JSON value. +fn enum_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let t: T = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; + serde_json::to_value(t).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))) + } + } +} + +/// A ruby enum parameter, checked against its type. +fn ruby_enum(p: &Value, key: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + serde_json::from_value::(v.clone()).map_err(|e| bad("type.ruby", format!("{key}: {e}")))?; + Ok(Some(v.clone())) +} + +/// A ruby number parameter clamped to `lo..=hi`, then divided by `div` (100 for percentages); +/// `null` gives `Value::Null` (automatic). +fn ruby_num(p: &Value, key: &str, lo: f64, hi: f64, div: f64) -> Result> { + match p.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(Value::Null)), + Some(v) => { + let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("type.ruby", format!("{key}: expected a finite number")))?; + Ok(Some(json!(x.clamp(lo, hi) / div))) + } + } +} + +pub(super) fn kenten_cmd(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::cjk_settings::{AdornmentOverprint, KENTEN_MARK_MAX_CHARS, KentenAlignment, KentenKind, KentenPosition}; + const CMD: &str = "type.kenten"; + let mut body = serde_json::Map::new(); + let mut set = |k: &str, v: Value| { + body.insert(k.to_string(), v); + }; + // Validate every setting first, so a bad one changes nothing. + if let Some(v) = enum_param::(p, "kind", CMD)? { + // A preset stores its mark as the character too, as IDML import does, so a custom + // character left from before isn't drawn in its place. + if let Some(mark) = serde_json::from_value::(v.clone()).ok().and_then(KentenKind::mark) { + set("kentenCharacter", json!(mark)); + } + set("kentenKind", v); + } + if let Some(c) = text_param(p, "character", CMD, KENTEN_MARK_MAX_CHARS)? { + set("kentenCharacter", json!(c.chars().filter(|c| !c.is_control()).collect::())); + } + for (key, attr) in [("font", "kentenFont"), ("fontStyle", "kentenFontStyle"), ("fill", "kentenFill"), ("stroke", "kentenStroke")] { + if let Some(t) = text_param(p, key, CMD, NAME_MAX_CHARS)? { + set(attr, json!(t)); + } + } + if let Some(v) = finite_param(p, "size", CMD)? { + set("kentenSize", json!(v.clamp(0.1, 1296.0))); + } + if let Some(v) = finite_param(p, "distance", CMD)? { + set("kentenDistance", json!(v.clamp(-1296.0, 1296.0))); + } + for (key, attr) in [("xScale", "kentenXScale"), ("yScale", "kentenYScale")] { + if let Some(v) = finite_param(p, key, CMD)? { + set(attr, json!(v.clamp(1.0, 1000.0) / 100.0)); + } + } + for (key, attr) in [("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint")] { + if let Some(v) = finite_param(p, key, CMD)? { + set(attr, json!(v.clamp(0.0, 100.0) / 100.0)); + } + } + if let Some(v) = finite_param(p, "strokeWeight", CMD)? { + set("kentenStrokeWeight", json!(v.clamp(0.0, 1000.0))); + } + if let Some(v) = enum_param::(p, "position", CMD)? { + set("kentenPosition", v); + } + if let Some(v) = enum_param::(p, "alignment", CMD)? { + set("kentenAlignment", v); + } + for (key, attr) in [("overprintFill", "kentenOverprintFill"), ("overprintStroke", "kentenOverprintStroke")] { + if let Some(v) = enum_param::(p, key, CMD)? { + set(attr, v); + } + } + // `null` returns a setting to the style's (size: half the text size by default). + for (key, attr) in + [("size", "kentenSize"), ("fillTint", "kentenFillTint"), ("strokeTint", "kentenStrokeTint"), ("strokeWeight", "kentenStrokeWeight")] + { + if p.get(key).is_some_and(Value::is_null) { + set(attr, Value::Null); + } + } + let on = match bool_param(p, "on", CMD)? { + Some(v) => v, + None if !body.is_empty() => true, + None => !char_flag_on(s, |f| f.kenten), + }; + if !on { + // Off keeps the settings for the next time kenten are switched on. + body = serde_json::Map::new(); + } + body.insert("kenten".into(), json!(on)); + format_chars(s, &Value::Object(body)) +} + +pub(super) fn shatai_cmd(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "type.shatai"; + let mut body = serde_json::Map::new(); + if let Some(v) = finite_param(p, "magnification", CMD)? { + body.insert("shataiMagnification".into(), json!(v.clamp(0.0, 90.0))); + } + if let Some(v) = finite_param(p, "angle", CMD)? { + body.insert("shataiAngle".into(), json!(v.rem_euclid(180.0))); + } + if let Some(v) = bool_param(p, "adjustRotation", CMD)? { + body.insert("shataiAdjustRotation".into(), json!(v)); + } + if let Some(v) = bool_param(p, "adjustTsume", CMD)? { + body.insert("shataiAdjustTsume".into(), json!(v)); + } + if body.is_empty() { + return Err(bad(CMD, "nothing to set: magnification, angle, adjustRotation or adjustTsume")); + } + format_chars(s, &Value::Object(body)) +} + +pub(super) fn auto_tcy_cmd(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "type.autoTateChuYoko"; + let mut body = serde_json::Map::new(); + if let Some(v) = finite_param(p, "digits", CMD)? { + let max = f64::from(designcraft_compose::shape::AUTO_TCY_MAX); + body.insert("autoTcy".into(), json!(v.round().clamp(0.0, max) as u32)); + } + if let Some(v) = bool_param(p, "includeRoman", CMD)? { + body.insert("autoTcyIncludeRoman".into(), json!(v)); + } + if body.is_empty() { + return Err(bad(CMD, "nothing to set: digits or includeRoman")); + } + format_paras(s, &Value::Object(body)) +} + +pub(super) fn grid_settings_cmd(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::cjk::CharacterAlignment as R; + const CMD: &str = "type.gridSettings"; + let mut body = serde_json::Map::new(); + let first_only = bool_param(p, "firstLineOnly", CMD)?; + match text_param(p, "alignment", CMD, 32)?.as_deref() { + None => { + if let Some(f) = first_only { + body.insert("gridAlign".into(), json!(if f { "firstLineOnly" } else { "allLines" })); + } + } + Some("none") => { + body.insert("gridAlign".into(), json!("none")); + } + Some(name) => { + let reference = match name { + "romanBaseline" | "baseline" => R::Baseline, + "emTop" => R::EmTop, + "emCenter" => R::EmCenter, + "emBottom" => R::EmBottom, + "icfTop" => R::IcfTop, + "icfBottom" => R::IcfBottom, + other => return Err(bad(CMD, format!("unknown alignment `{other}`"))), + }; + body.insert("gridAlign".into(), json!(if first_only == Some(true) { "firstLineOnly" } else { "allLines" })); + body.insert("gridReference".into(), json!(reference)); + } + } + if let Some(v) = finite_param(p, "gyoudori", CMD)? { + let max = 100.0; + body.insert("gridGyoudori".into(), json!(v.round().clamp(0.0, max) as u32)); + } + if let Some(v) = bool_param(p, "paragraphGyoudori", CMD)? { + body.insert("paragraphGyoudori".into(), json!(v)); + } + if body.is_empty() { + return Err(bad(CMD, "nothing to set: alignment, firstLineOnly, gyoudori or paragraphGyoudori")); + } + format_paras(s, &Value::Object(body)) +} + +fn ruby_text<'a>(p: &'a Value, key: &str) -> Result> { + let Some(v) = p.get(key) else { return Ok(None) }; + let t = v.as_str().ok_or_else(|| bad("type.ruby", format!("{key}: expected a string")))?; + if t.chars().count() > designcraft_doc::ruby::MAX_RUBY_CHARS { + return Err(bad("type.ruby", format!("{key}: longer than {} characters", designcraft_doc::ruby::MAX_RUBY_CHARS))); + } + Ok(Some(t)) +} + +pub(super) fn ruby_cmd(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::cjk_settings::AdornmentOverprint; + use designcraft_doc::ruby::{RubyAlignment, RubyOverhang, RubyParentSpacing, RubyPosition, RubyType}; + let mut body = serde_json::Map::new(); + let mut put = |attr: &str, v: Option| { + if let Some(v) = v { + body.insert(attr.into(), v); + } + }; + put("ruby", ruby_text(p, "text")?.map(|t| json!(t))); + put("rubyType", ruby_enum::(p, "type")?); + put("rubyAlignment", ruby_enum::(p, "alignment")?); + put("rubyPosition", ruby_enum::(p, "position")?); + put("rubyOverhangAmount", ruby_enum::(p, "overhang")?); + put("rubyParentSpacing", ruby_enum::(p, "parentSpacing")?); + put("rubyOverprintFill", ruby_enum::(p, "overprintFill")?); + put("rubyOverprintStroke", ruby_enum::(p, "overprintStroke")?); + put("rubyXOffset", ruby_num(p, "xOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); + put("rubyYOffset", ruby_num(p, "yOffset", -1000.0, 1000.0, 1.0)?.filter(|v| !v.is_null())); + put("rubyFontSize", ruby_num(p, "size", 0.1, 1296.0, 1.0)?); + put("rubyXScale", ruby_num(p, "xScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); + put("rubyYScale", ruby_num(p, "yScale", 1.0, 1000.0, 100.0)?.filter(|v| !v.is_null())); + put("rubyScalingMin", ruby_num(p, "scalingPercent", 10.0, 100.0, 100.0)?.filter(|v| !v.is_null())); + put("rubyFillTint", ruby_num(p, "fillTint", 0.0, 100.0, 100.0)?); + put("rubyStrokeTint", ruby_num(p, "strokeTint", 0.0, 100.0, 100.0)?); + put("rubyStrokeWeight", ruby_num(p, "strokeWeight", 0.0, 800.0, 1.0)?); + put( + "rubyAutoTcyDigits", + ruby_num(p, "autoTcyDigits", 0.0, 9.0, 1.0)?.filter(|v| !v.is_null()).and_then(|v| v.as_f64()).map(|v| json!(v.round() as u32)), + ); + for (key, attr) in [ + ("openTypePro", "rubyOpenTypePro"), + ("autoTcyIncludeRoman", "rubyAutoTcyIncludeRoman"), + ("autoTcyAutoScale", "rubyAutoTcyAutoScale"), + ("autoAlign", "rubyAutoAlign"), + ("autoScaling", "rubyAutoScaling"), + ] { + if let Some(v) = p.get(key) { + let b = v.as_bool().ok_or_else(|| bad("type.ruby", format!("{key}: expected true or false")))?; + put(attr, Some(json!(b))); + } + } + put("rubyFont", ruby_text(p, "font")?.map(|t| json!(t))); + put("rubyFontStyle", ruby_text(p, "fontStyle")?.map(|t| json!(t))); + for (key, attr) in [("fill", "rubyFill"), ("stroke", "rubyStroke")] { + if let Some(name) = ruby_text(p, key)? { + let known = name.is_empty() || name == designcraft_doc::color::swatch::NONE || s.doc()?.doc.swatch(name).is_some(); + if !known { + return Err(bad("type.ruby", format!("{key}: no swatch `{name}`"))); + } + put(attr, Some(json!(name))); + } + } + format_chars(s, &Value::Object(body)) +} + +#[cfg(test)] +mod tcy_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn tate_chu_yoko_toggles_on_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "令和12年", "vertical": true})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let at = "令和".len(); + s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at + 2})).unwrap(); + let get = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(at + 1).over.tate_chu_yoko; + s.execute("type.tateChuYoko", &json!({})).unwrap(); + assert_eq!(get(&s), Some(true)); + s.execute("type.tateChuYoko", &json!({})).unwrap(); + assert_eq!(get(&s), Some(false)); + s.execute("type.tateChuYoko", &json!({"on": true})).unwrap(); + assert_eq!(get(&s), Some(true)); + } + + #[test] + fn ruby_and_kenten_on_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); + s.execute("type.ruby", &json!({"text": "かんじ"})).unwrap(); + s.execute("type.kenten", &json!({})).unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + assert_eq!(f(&s).ruby.as_deref(), Some("かんじ")); + assert_eq!(f(&s).kenten, Some(true)); + s.execute("type.kenten", &json!({})).unwrap(); + s.execute("type.ruby", &json!({"text": ""})).unwrap(); + assert_eq!(f(&s).kenten, Some(false)); + assert_eq!(f(&s).ruby.as_deref(), Some("")); + } + + #[test] + fn ruby_options_are_checked_and_clamped() { + use designcraft_doc::ruby::{RubyAlignment, RubyPosition, RubyType}; + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "漢字です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": "漢字".len()})).unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + s.execute( + "type.ruby", + &json!({"text": "かん\u{3000}じ", "type": "perCharacter", "alignment": "oneAki", "position": "belowLeft", "size": 1e9, "xScale": -50, "scalingPercent": 5, "autoTcyDigits": 99, "fillTint": 250, "yOffset": -1e12, "fill": "[Black]"}), + ) + .unwrap(); + let a = f(&s); + assert_eq!( + (a.ruby.as_deref(), a.ruby_type, a.ruby_position), + (Some("かん\u{3000}じ"), Some(RubyType::PerCharacter), Some(RubyPosition::BelowLeft)) + ); + assert_eq!(a.ruby_font_size, Some(Some(1296.0))); + assert_eq!(a.ruby_x_scale, Some(0.01)); + assert_eq!(a.ruby_scaling_min, Some(0.1)); + assert_eq!(a.ruby_auto_tcy_digits, Some(9)); + assert_eq!(a.ruby_fill_tint, Some(Some(1.0))); + assert_eq!(a.ruby_y_offset, Some(-1000.0)); + // Options alone keep the reading. + s.execute("type.ruby", &json!({"alignment": "center"})).unwrap(); + assert_eq!((f(&s).ruby, f(&s).ruby_alignment), (a.ruby.clone(), Some(RubyAlignment::Center))); + // Refused, and nothing changes: unknown names, non-numbers, overlong text, unknown swatches. + let long = "あ".repeat(designcraft_doc::ruby::MAX_RUBY_CHARS + 1); + for p in [ + json!({"alignment": "sideways"}), + json!({"size": "big"}), + json!({"text": long}), + json!({"fill": "Nope"}), + json!({"autoAlign": 1}), + json!({"text": 5}), + ] { + assert!(s.execute("type.ruby", &p).is_err(), "{p}"); + } + assert_eq!(f(&s).ruby_alignment, Some(RubyAlignment::Center)); + assert_eq!(f(&s).ruby, a.ruby); + } + + #[test] + fn warichu_toggles_on_the_selection_and_undoes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let n = "注釈".len(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap(); + s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2})) + .unwrap(); + let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone(); + let a = f(&s); + assert_eq!(a.warichu, Some(true)); + assert_eq!(a.warichu_lines, Some(2)); + assert_eq!(a.warichu_size, Some(40.0)); + assert_eq!(a.warichu_line_spacing, Some(1.0)); + assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left)); + assert_eq!(a.warichu_chars_before_break, Some(2)); + assert_eq!(a.warichu_chars_after_break, Some(2)); + // The characters after the selection are not part of the note. + assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, None); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, Some(true)); + s.execute("type.warichu", &json!({})).unwrap(); + assert_eq!(f(&s).warichu, Some(false)); + } + + #[test] + fn warichu_up_and_down_move_between_the_rows() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap(); + s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert!((4..8).contains(&down), "down enters the second row: {down}"); + let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert!(up < 4, "up returns to the first row: {up}"); + s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap(); + let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize; + assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row"); + } +} diff --git a/crates/engine/src/cmd/text/caret.rs b/crates/engine/src/cmd/text/caret.rs new file mode 100644 index 000000000..670de4049 --- /dev/null +++ b/crates/engine/src/cmd/text/caret.rs @@ -0,0 +1,764 @@ +use super::{ + CellAddr, Content, ItemId, Point, Result, Selection, Session, StoryId, TextSel, Value, bad, bool_or, compose, floor_char_boundary, id_param, + json, ok, point_param, str_param, +}; + +pub(crate) fn story_of(s: &Session, p: &Value) -> Option { + let st = s.active()?; + if let Some(v) = p.get("story").and_then(Value::as_u64) { + return Some(StoryId(v)); + } + let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()); + if let Some(f) = frame { + return st.doc.item(f).and_then(|i| i.text_frame()).map(|t| t.story); + } + st.selection.text.map(|t| t.story) +} + +/// Story byte at spread point `pt` in text frame `frame` (in a table cell's story when the point is in a cell). +fn hit_byte(s: &Session, frame: ItemId, pt: Point) -> Option<(StoryId, usize, Option)> { + hit_byte_with(s, frame, pt, compose::hit) +} + +/// [`hit_byte`] with the hit test: [`compose::hit`] (caret position) or [`compose::hit_char`] +/// (the character under the point). +pub(super) fn hit_byte_with( + s: &Session, + frame: ItemId, + pt: Point, + hit: fn(&compose::ComposedStory, usize, Point) -> Option, +) -> Option<(StoryId, usize, Option)> { + let st = s.active()?; + let loc = st.doc.find(frame)?; + let it = st.doc.item_at(&loc)?; + let sid = it.text_frame()?.story; + let xf = st.doc.parent_xf(&loc) * st.doc.text_xf(it); + let inner = xf.inverse() * pt; + let cs = s.cache.get(&st.doc, sid, None); + let fi = cs.frames.iter().position(|f| f.frame == frame)?; + // Map path-text hits along the curve before choosing the character unit. + let inner = cs.frames.get(fi).and_then(|ft| compose::PathTextMap::new(it, ft)).map_or(inner, |m| m.to_line(inner)); + if let Some((table, row, col, b)) = compose::hit_cell_with(&cs, fi, inner, hit) { + return Some((sid, b, Some(CellAddr { table, row, col }))); + } + if let Some((id, b)) = compose::hit_note_with(&cs, fi, inner, hit) { + return Some((sid, b, Some(CellAddr::footnote(id)))); + } + let b = hit(&cs, fi, inner).unwrap_or(0); + Some((sid, b, None)) +} + +/// Select the word, line, paragraph or story at `point` in `frame` (multiple clicks). +pub(super) fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { + const ID: &str = "text.selectAt"; + let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; + let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; + let (a, e) = unit_at(s, sid, cell, b, unit, ID)?; + s.text_drag = false; + s.typing_format = None; + let sel = TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }; + s.text_unit = Some(sel); + let st = s.doc_mut()?; + st.selection = Selection::text(sel); + // Explicit reselection invalidates the Find scope even when the range is unchanged. + st.bump_revision(); + Ok(json!({"story": sid.0, "anchor": a, "focus": e})) +} + +/// The `unit` (word, line, paragraph or story) around byte `b` of story `sid` (a cell's story +/// with `cell`), as the multiple clicks select it. +fn unit_at(s: &Session, sid: StoryId, cell: Option, b: usize, unit: &str, cmd: &str) -> Result<(usize, usize)> { + let st = s.doc()?; + let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let text = story.text.as_str(); + let b = floor_char_boundary(text, b); + Ok(match unit { + "word" => word_or_spaces_at(text, b), + "line" => { + let cs = s.cache.get(&st.doc, sid, None); + line_at(&text_composition(cs, cell), b).unwrap_or((b, b)) + } + // The paragraph with its return (the last paragraph has none). + "paragraph" => story.para_ranges().get(story.para_at(b)).map_or((b, b), |r| (r.start, (r.end + 1).min(text.len()))), + "story" => (0, text.len()), + other => return Err(bad(cmd, format!("unknown unit `{other}` (word, line, paragraph or story)"))), + }) +} + +/// The word at byte `i`; on spaces the run of spaces; on anything else that one character +/// (grapheme cluster). At the end of a paragraph, what ends it. Grapheme clusters are never split: +/// combining marks, viramas, variation selectors and emoji ZWJ sequences stay with their base. +fn word_or_spaces_at(s: &str, i: usize) -> (usize, usize) { + use unicode_segmentation::UnicodeSegmentation; + let i = floor_char_boundary(s, i.min(s.len())); + // Clusters never span a paragraph return, so the paragraph is enough. + let ps = s[..i].rfind('\n').map_or(0, |k| k + 1); + let pe = s[i..].find('\n').map_or(s.len(), |k| i + k); + let g: Vec<(usize, &str)> = s[ps..pe].grapheme_indices(true).collect(); + let Some(k) = g.iter().rposition(|(o, _)| ps + o <= i) else { return (i, i) }; + let kind = |c: &str| match c.chars().next() { + Some(c) if is_word(c) => 1, + Some(c) if c.is_whitespace() => 2, + _ => 0, + }; + let span = |a: usize, e: usize| g.get(a).zip(g.get(e)).map_or((i, i), |((oa, _), (oe, ge))| (ps + oa, ps + oe + ge.len())); + let kd = g.get(k).map_or(0, |(_, c)| kind(c)); + if kd == 0 { + return span(k, k); + } + let mut a = k; + while a > 0 && g.get(a - 1).is_some_and(|(_, c)| kind(c) == kd) { + a -= 1; + } + let mut e = k; + while g.get(e + 1).is_some_and(|(_, c)| kind(c) == kd) { + e += 1; + } + span(a, e) +} + +/// The composition whose lines hold a story's text: the story's own, a table cell's or a footnote's. +fn text_composition(cs: std::sync::Arc, cell: Option) -> std::sync::Arc { + match cell { + Some(c) if c.footnote_id().is_some() => compose::find_note(&cs, c.row as u64).map(|(_, n)| n.text.clone()).unwrap_or(cs), + Some(c) => compose::find_cell(&cs, c.table, c.row, c.col).map(|(_, _, pc)| pc.text.clone()).unwrap_or(cs), + None => cs, + } +} + +/// The composed line holding the character at byte `pos`. +fn line_at(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { + let lines = || cs.frames.iter().flat_map(|f| f.lines.iter()); + lines().find(|l| l.range.contains(&pos)).map(|l| (l.range.start, l.range.end)).or_else(|| line_of(cs, pos)) +} + +pub(super) fn place(s: &mut Session, p: &Value, extend: bool) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad("text.placeCaret", "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + // Empty frames become text frames; a locked one stays as it is. + let st = s.doc()?; + let locked = st.doc.item(frame).is_some_and(|i| i.locked || st.doc.layer(i.layer).is_some_and(|l| l.locked)); + let needs_convert = st.doc.item(frame).is_some_and(|i| matches!(i.content, Content::Unassigned)); + if needs_convert && locked { + return Err(bad("text.placeCaret", "the frame is locked")); + } + if needs_convert { + s.execute("object.content", &json!({"ids": [frame.0], "type": "text"}))?; + } + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad("text.placeCaret", "not a text frame"))?; + // A drag after several clicks: whole units from the one first selected, in either direction. + if extend + && let Some(unit) = str_param(p, "unit") + && let Some(u) = s.text_unit.filter(|u| u.story == sid && u.cell == cell) + { + // The remembered unit may predate edits that shortened the text. + let text = s.doc()?.doc.text_story(sid, cell).map_or("", |t| t.text.as_str()); + let (ua, ue) = (floor_char_boundary(text, u.anchor.min(u.focus)), floor_char_boundary(text, u.anchor.max(u.focus))); + let (na, ne) = unit_at(s, sid, cell, b, unit, "text.extendTo")?; + let (anchor, focus) = if na < ua { (ue, na) } else { (ua, ne.max(ue)) }; + s.typing_format = None; + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.bump_revision(); + return Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})); + } + // Drag and drop: a press inside the selected text keeps it (to be dragged). + if !extend { + s.text_drag = s.doc()?.selection.text.is_some_and(|t| t.story == sid && t.cell == cell && !t.range().is_empty() && t.range().contains(&b)); + if s.text_drag { + return Ok(json!({"story": sid.0, "dragging": true})); + } + } else if s.text_drag { + return Ok(json!({"story": sid.0, "dragging": true})); + } + s.typing_format = None; + let st = s.doc_mut()?; + // Dragging from one cell into another selects cells. + if extend + && let (Some(t), Some(c)) = (st.selection.text, cell) + && let Some(a) = t.cell + && t.story == sid + && a.table == c.table + && (a.row, a.col) != (c.row, c.col) + { + let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col); + st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range }); + st.bump_revision(); + return Ok(json!({"story": sid.0, "cells": range})); + } + let t = match (extend, st.selection.text) { + (true, Some(t)) if t.story == sid && t.cell == cell => TextSel { focus: b, frame: Some(frame), ..t }, + _ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell }, + }; + st.selection = Selection::text(t); + st.bump_revision(); + Ok(json!({"story": sid.0, "pos": b, "cell": cell})) +} + +pub(super) fn release(s: &mut Session, p: &Value) -> Result { + if !std::mem::take(&mut s.text_drag) { + return ok(); + } + let frame = id_param(p, "frame").ok_or_else(|| bad("text.release", "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + if !p.get("moved").and_then(Value::as_bool).unwrap_or(false) { + // A click in the selection: the caret goes there. + s.doc_mut()?.selection.text = None; + return place(s, &json!({"frame": frame.0, "point": [pt.x, pt.y]}), false); + } + let copy = p.get("copy").and_then(Value::as_bool).unwrap_or(false); + let Some(t) = s.doc()?.selection.text else { return ok() }; + let Some((sid, b, cell)) = hit_byte(s, frame, pt) else { return ok() }; + let r = t.range(); + if sid != t.story || cell != t.cell || (r.contains(&b) && !copy) { + return ok(); + } + s.edit(|d, sel| { + let st = d.text_story_mut(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let piece = st.extract(r.clone()); + let mut at = b; + if !copy { + st.delete(r.clone()); + if at > r.start { + at -= r.len().min(at - r.start); + } + } + let end = st.insert_story(at, &piece); + sel.text = Some(TextSel { story: sid, anchor: at, focus: end, frame: Some(frame), cell }); + Ok(json!({"moved": piece.text.len(), "at": at})) + }) +} + +pub fn next_char(s: &str, i: usize) -> usize { + s[i.min(s.len())..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) +} + +pub fn prev_char(s: &str, i: usize) -> usize { + s[..i.min(s.len())].chars().next_back().map(|c| i - c.len_utf8()).unwrap_or(0) +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '\'' || c == '’' +} + +pub fn next_word(s: &str, i: usize) -> usize { + let mut j = i; + let mut seen = false; + for (k, c) in s[i..].char_indices() { + if is_word(c) { + seen = true; + } else if seen { + return i + k; + } + j = i + k + c.len_utf8(); + } + j +} + +pub fn prev_word(s: &str, i: usize) -> usize { + let mut seen = false; + for (k, c) in s[..i].char_indices().rev() { + if is_word(c) { + seen = true; + } else if seen { + return k + c.len_utf8(); + } + } + 0 +} + +/// Selects `bounds(text, byte)` around the byte under `point` in `frame`. Unlike a caret +/// placement, a press inside the current selection is no start of a text drag here: it is the +/// third or later click of a row, which lands in the word or sentence just selected. +pub(super) fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) -> (usize, usize)) -> Result { + let frame = id_param(p, "frame").ok_or_else(|| bad(id, "missing frame"))?; + let pt = point_param(p, "point").unwrap_or(Point::ZERO); + let (sid, b, cell) = hit_byte(s, frame, pt).ok_or_else(|| bad(id, "not a text frame"))?; + let st = s.doc_mut()?; + let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); + let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); + s.text_drag = false; + s.typing_format = None; + let st = s.doc_mut()?; + st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); + st.bump_revision(); + Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) +} + +/// The paragraph around byte `i`, without its `\n`. +pub(super) fn para_bounds(s: &str, i: usize) -> (usize, usize) { + let i = i.min(s.len()); + let a = s.get(..i).and_then(|t| t.rfind('\n')).map_or(0, |k| k + 1); + let b = s.get(i..).and_then(|t| t.find('\n')).map_or(s.len(), |k| i + k); + (a, b) +} + +/// The sentence around byte `i`, within its paragraph: it ends after `.`, `!`, `?` or `…` (and +/// any closing quotes or brackets) followed by a space or the paragraph end, or after a CJK full +/// stop, and takes the spaces after it, as deleting a sentence should leave none behind. +pub(super) fn sentence_bounds(s: &str, i: usize) -> (usize, usize) { + let (pa, pb) = para_bounds(s, i); + let para = s.get(pa..pb).unwrap_or(""); + let mut start = 0; + let mut it = para.char_indices().peekable(); + while let Some((_, c)) = it.next() { + let cjk = matches!(c, '。' | '!' | '?'); + if !cjk && !matches!(c, '.' | '!' | '?' | '…') { + continue; + } + while it.peek().is_some_and(|&(_, c)| { + matches!(c, '.' | '!' | '?' | '…' | '。' | '!' | '?' | '"' | '\'' | '”' | '’' | '»' | '›' | ')' | ']' | '」' | '』' | ')') + }) { + it.next(); + } + if !cjk && it.peek().is_some_and(|&(_, c)| !c.is_whitespace()) { + continue; + } + while it.peek().is_some_and(|&(_, c)| c.is_whitespace()) { + it.next(); + } + let end = it.peek().map_or(para.len(), |&(k, _)| k); + if pa + end > i || end == para.len() { + return (pa + start, pa + end); + } + start = end; + } + (pa + start, pb) +} + +pub(super) fn move_caret(s: &mut Session, p: &Value) -> Result { + let dir = str_param(p, "dir").unwrap_or("right").to_string(); + let extend = bool_or(p, "extend", false); + let word = bool_or(p, "word", false); + let st = s.doc()?; + let t = st.selection.text.ok_or_else(|| bad("text.move", "no caret"))?; + let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let text = story.text.clone(); + // In a cell, lines come from the cell's own composition. + let cs = text_composition(s.cache.get(&st.doc, t.story, None), t.cell); + let pos = t.focus.min(text.len()); + // Vertical lines run top to bottom and follow each other right to left: Down and Up move along + // the line (Right and Left of horizontal text), Left and Right to the next and previous line. + let vertical_text = compose::caret(&cs, pos).and_then(|(fi, ..)| cs.frames.get(fi)).is_some_and(|f| f.vertical); + let dir = match (vertical_text, dir.as_str()) { + (true, "down") => "right", + (true, "up") => "left", + (true, "left") => "down", + (true, "right") => "up", + (_, d) => d, + }; + let collapse_to = |left: bool| if left { t.range().start } else { t.range().end }; + let new = match dir { + "left" if !extend && !t.is_caret() => collapse_to(true), + "right" if !extend && !t.is_caret() => collapse_to(false), + "left" | "right" => { + // Lines with right-to-left text move as drawn; at a line's edge, or by words, a + // right-to-left line's "left" is forward in the text. + let left = dir == "left"; + let visual = if word { None } else { compose::visual_step(&cs, pos, left) }; + match visual { + Some(p) => p, + None => { + let forward = left == compose::line_rtl(&cs, pos); + match (forward, word) { + (true, true) => next_word(&text, pos), + (true, false) => next_char(&text, pos), + (false, true) => prev_word(&text, pos), + (false, false) => prev_char(&text, pos), + } + } + } + } + "up" | "down" => vertical(&cs, pos, dir == "up").unwrap_or(pos), + "lineStart" => line_of(&cs, pos).map(|l| l.0).unwrap_or(0), + "lineEnd" => line_of(&cs, pos).map(|l| l.1).unwrap_or(text.len()), + "storyStart" => 0, + "storyEnd" => text.len(), + other => return Err(bad("text.move", format!("unknown dir `{other}`"))), + }; + s.typing_format = None; + let stm = s.doc_mut()?; + stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); + stm.bump_revision(); + Ok(json!({"pos": new})) +} + +fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> { + cs.frames.iter().flat_map(|f| f.lines.iter()).find(|l| pos >= l.range.start && pos <= l.range.end).map(|l| (l.range.start, l.range.end)) +} + +fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option { + let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?; + let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect(); + // Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it + // does not equal the parent line's baseline. + let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?; + let (_, l) = lines.get(cur)?; + if let Some(y) = compose::adjacent_row(l, x, caret_y, up) + && let Some(p) = compose::hit(cs, fi, Point::new(x, y)) + && p != pos + { + return Some(p); + } + // The next row: segments of the same line (beside a wrap object) share its baseline. + let other_row = |(i, t): &&(usize, &compose::Line)| *i != fi || (t.baseline - l.baseline).abs() > 1e-6; + let (tf, tl) = if up { lines.get(..cur)?.iter().rfind(other_row)? } else { lines.get(cur + 1..)?.iter().find(other_row)? }; + compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0)) +} + +#[cfg(test)] +mod multi_click_tests { + use serde_json::{Value, json}; + + use crate::Session; + + #[test] + fn moving_the_caret_drops_the_pending_typing_format() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let size_at = |s: &Session, b: usize| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + let pending = |s: &mut Session| { + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + }; + // Away and back by arrow keys, by a click, by selecting and by double-clicking: typing at the + // same caret is in the text's own format. + let moves: Vec> = vec![ + Box::new(|s| { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + s.execute("text.move", &json!({"dir": "left"})).unwrap(); + }), + Box::new(|s| { + s.execute("text.placeCaret", &json!({"frame": fid, "point": [80, 80]})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + Box::new(|s| { + s.execute("text.selectAt", &json!({"frame": fid, "point": [80, 80], "unit": "story"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + }), + ]; + for m in &moves { + pending(&mut s); + m(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), None); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Without a move it applies. + pending(&mut s); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(size_at(&s, 6), Some(30.0)); + // Selecting a sentence or paragraph around a point drops it too. + for id in ["text.selectSentence", "text.selectParagraph"] { + pending(&mut s); + assert!(s.typing_format.is_some()); + s.execute(id, &json!({"frame": fid, "point": [80, 80]})).unwrap(); + assert!(s.typing_format.is_none(), "{id}"); + } + } + + #[test] + fn word_selection_keeps_grapheme_clusters_whole() { + use super::word_or_spaces_at as word; + // Decomposed "café": the combining acute belongs to the word, wherever the click lands. + let s = "un cafe\u{301} noir"; + let cafe = (3, 3 + "cafe\u{301}".len()); + assert_eq!(word(s, 4), cafe); + assert_eq!(word(s, 7), cafe, "on the combining mark"); + // Devanagari with a virama (U+094D) and a dependent vowel sign. + let s = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947} x"; + let w = "\u{928}\u{92E}\u{938}\u{94D}\u{924}\u{947}".len(); + assert_eq!(word(s, 0), (0, w)); + assert_eq!(word(s, 9), (0, w), "on the virama"); + // An emoji ZWJ sequence and a variation selector stay one character. + let s = "a \u{1F469}\u{200D}\u{1F4BB} b \u{263A}\u{FE0F}"; + let e = (2, 2 + "\u{1F469}\u{200D}\u{1F4BB}".len()); + assert_eq!(word(s, 2), e); + assert_eq!(word(s, 6), e, "on the joiner"); + assert_eq!(word(s, s.len() - 1), (s.len() - 6, s.len()), "at the end: the last character whole"); + // Empty paragraphs select nothing. + assert_eq!(word("a\n\nb", 2), (2, 2)); + } + + /// Issue #981: holding the button after a double, triple or quadruple click and moving extends + /// the selection a word, line or paragraph at a time, from the unit first selected, either way. + #[test] + fn a_drag_after_several_clicks_extends_the_selection_by_units() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Hello brave world.\nSecond paragraph here."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); + json!([g.x + g.adv * 0.75, l.baseline - 3.0]) + }; + let sel = |s: &Session| s.doc().unwrap().selection.text.map(|t| (t.anchor, t.focus)).unwrap(); + let extend = |s: &mut Session, b: usize, unit: &str| { + let p = at(s, b); + s.execute("text.extendTo", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); + sel(s) + }; + // Double-click "brave", drag into "world.": whole words, anchored at the start of "brave". + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); + assert_eq!(sel(&s), (7, 12)); + assert_eq!(extend(&mut s, 14, "word"), (7, 18), "forward: through \"world\""); + assert_eq!(extend(&mut s, 9, "word"), (7, 12), "back inside the word: just the word"); + assert_eq!(extend(&mut s, 1, "word"), (12, 0), "backward: from \"Hello\" to the end of \"brave\""); + assert_eq!(&text[0..12], "Hello brave"); + // Triple-click the first line, drag into the second: both lines. + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "line"})).unwrap(); + assert_eq!(sel(&s), (0, 19)); + assert_eq!(extend(&mut s, 22, "line"), (0, text.len())); + // Without `unit` a drag still moves the focus a character at a time. + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 8), "unit": "word"})).unwrap(); + s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14)})).unwrap(); + assert_eq!(sel(&s).0, 7); + assert!(sel(&s).1 > 12 && sel(&s).1 < 18, "{:?}", sel(&s)); + assert!(s.execute("text.extendTo", &json!({"frame": fid, "point": at(&s, 14), "unit": "sentence"})).is_err()); + // A unit remembered before the text was deleted stays inside what is left. + s.execute("text.selectAt", &json!({"frame": fid, "point": at(&s, 14), "unit": "word"})).unwrap(); + s.execute("story.setText", &json!({"story": sid, "text": ""})).unwrap(); + s.execute("text.extendTo", &json!({"frame": fid, "point": [100, 90], "unit": "word"})).unwrap(); + assert_eq!(sel(&s), (0, 0)); + } + + #[test] + fn clicks_select_words_spaces_lines_paragraphs_and_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Hello brave world.\nSecond paragraph here."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": text})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + // Spread point over the right half of the glyph at byte `b`. + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let (l, g) = cs.frames[0].lines.iter().find_map(|l| l.glyphs.iter().find(|g| g.byte == b && g.len > 0).map(|g| (l, g))).unwrap(); + json!([g.x + g.adv * 0.75, l.baseline - 3.0]) + }; + let mut select = |unit: &str, b: usize| { + let p = at(&s, b); + let r: Value = s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": unit})).unwrap(); + (r["anchor"].as_u64().unwrap() as usize, r["focus"].as_u64().unwrap() as usize) + }; + assert_eq!(select("word", 1), (0, 5), "inside a word"); + assert_eq!(select("word", 4), (0, 5), "its last letter"); + assert_eq!(select("word", 5), (5, 7), "the run of spaces after it"); + assert_eq!(select("word", 18), (18, 19), "punctuation alone"); + assert_eq!(select("line", 9), (0, 19)); + assert_eq!(select("paragraph", 9), (0, 20), "with its return"); + assert_eq!(select("paragraph", 22), (20, text.len())); + assert_eq!(select("story", 9), (0, text.len())); + // A second click inside the selected word selects again rather than starting a text drag. + s.execute("text.select", &json!({"story": sid, "anchor": 7, "focus": 12})).unwrap(); + let p = at(&s, 9); + s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "line"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19)); + s.execute("text.release", &json!({"frame": fid, "point": p, "moved": false})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 19), "releasing keeps it"); + // The older word command agrees. + let p = at(&s, 8); + s.execute("text.selectWord", &json!({"frame": fid, "point": p})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (7, 12)); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "point": p, "unit": "sentence"})).is_err()); + assert!(s.execute("text.selectAt", &json!({"frame": fid, "unit": "word"})).is_err(), "no point"); + } +} + +#[cfg(test)] +mod drag_text_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn drag_selected_text_to_move_or_copy_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Hello brave world"})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + // Spread point of the glyph at byte `b`. + let at = |s: &Session, b: usize| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let l = &cs.frames[0].lines[0]; + let g = l.glyphs.iter().find(|g| g.byte == b).unwrap(); + json!([g.x + g.adv * 0.25, l.baseline - 3.0]) + }; + let end = |s: &Session| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default()); + let l = &cs.frames[0].lines[0]; + json!([l.end_x + 2.0, l.baseline - 3.0]) + }; + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 12})).unwrap(); + let r = s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 8)})).unwrap(); + assert_eq!(r["dragging"], true); + s.execute("text.extendTo", &json!({"frame": fid, "point": end(&s)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": end(&s), "moved": true})).unwrap(); + let text = |s: &Session| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone(); + assert_eq!(text(&s), "Hello worldbrave "); + // Alt-drag copies (the moved text is still selected). + s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 12)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": at(&s, 0), "moved": true, "copy": true})).unwrap(); + assert_eq!(text(&s), "brave Hello worldbrave "); + // A click inside the selection just places the caret. + s.execute("text.placeCaret", &json!({"frame": fid, "point": at(&s, 2)})).unwrap(); + s.execute("text.release", &json!({"frame": fid, "point": at(&s, 2), "moved": false})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!(t.anchor, t.focus); + } +} + +#[cfg(test)] +mod bidi_caret_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn arrow_keys_in_a_right_to_left_paragraph() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "سلام عليكم"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("type.para", &json!({"attrs": {"direction": "rightToLeft"}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + // Left moves forward through right-to-left text, Right back. + let p = s.execute("text.move", &json!({"dir": "left"})).unwrap()["pos"].as_u64().unwrap(); + assert_eq!(p, 'س'.len_utf8() as u64); + let p = s.execute("text.move", &json!({"dir": "right"})).unwrap()["pos"].as_u64().unwrap(); + assert_eq!(p, 0); + // By words too. + let p = s.execute("text.move", &json!({"dir": "left", "word": true})).unwrap()["pos"].as_u64().unwrap(); + assert!(p >= "سلام".len() as u64, "{p}"); + } +} + +#[cfg(test)] +mod vertical_caret_tests { + use designcraft_doc::StoryId; + use serde_json::json; + + use crate::Session; + + /// A story set in three or more lines; returns its id. + fn story(s: &mut Session, vertical: bool) -> StoryId { + s.execute("file.new", &json!({})).unwrap(); + let text = "一二三四五六七八九十".repeat(8); + let rect = if vertical { [72, 72, 200, 400] } else { [72, 72, 400, 200] }; + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "vertical": vertical})).unwrap(); + StoryId(r["story"].as_u64().unwrap()) + } + + fn mv(s: &mut Session, dir: &str, extend: bool, word: bool) -> usize { + s.execute("text.move", &json!({"dir": dir, "extend": extend, "word": word})).unwrap()["pos"].as_u64().unwrap() as usize + } + + /// (line index, position along the line) of the caret at `pos`. + fn place(s: &Session, sid: StoryId, pos: usize) -> (usize, f64) { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let (_, x, baseline, _, _) = crate::compose::caret(&cs, pos).unwrap(); + let line = cs.frames[0].lines.iter().position(|l| (l.baseline - baseline).abs() < 0.01).unwrap(); + (line, x) + } + + #[test] + fn arrow_keys_follow_vertical_lines() { + let mut s = Session::new(); + let sid = story(&mut s, true); + assert!(s.cache.get(&s.doc().unwrap().doc, sid, None).frames[0].lines.len() >= 3); + let c = '一'.len_utf8(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); + // Down and Up move along the line, a character at a time. + assert_eq!(mv(&mut s, "down", false, false), 4 * c); + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + // Left moves to the next line (lines follow each other right to left), keeping the + // position along the line; Right comes back. + let (line, x) = place(&s, sid, 3 * c); + let next = mv(&mut s, "left", false, false); + let (nline, nx) = place(&s, sid, next); + assert_eq!(nline, line + 1); + assert!((nx - x).abs() < 0.5, "{x} {nx}"); + assert_eq!(mv(&mut s, "right", false, false), 3 * c); + // Shift extends. + assert_eq!(mv(&mut s, "down", true, false), 4 * c); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (3 * c, 4 * c)); + let p = mv(&mut s, "left", true, false); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (3 * c, p)); + assert_eq!(place(&s, sid, p).0, line + 1); + // With a selection, Up collapses it to its start. + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + // By words: Alt+Down is the vertical Alt+Right. + let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone(); + assert_eq!(mv(&mut s, "down", false, true), super::next_word(&text, 3 * c)); + } + + #[test] + fn arrow_keys_in_horizontal_lines() { + let mut s = Session::new(); + let sid = story(&mut s, false); + let c = '一'.len_utf8(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3 * c, "focus": 3 * c})).unwrap(); + assert_eq!(mv(&mut s, "right", false, false), 4 * c); + assert_eq!(mv(&mut s, "left", false, false), 3 * c); + let (line, x) = place(&s, sid, 3 * c); + let next = mv(&mut s, "down", false, false); + let (nline, nx) = place(&s, sid, next); + assert_eq!(nline, line + 1); + assert!((nx - x).abs() < 0.5, "{x} {nx}"); + assert_eq!(mv(&mut s, "up", false, false), 3 * c); + } +} + +#[cfg(test)] +mod sentence_selection_tests { + use super::{para_bounds, sentence_bounds}; + + fn sentence(s: &str, at: &str) -> String { + let i = s.find(at).unwrap(); + let (a, b) = sentence_bounds(s, i); + s[a..b].to_string() + } + + #[test] + fn sentences_end_at_a_stop_before_a_space_or_the_paragraph_end() { + let s = "It costs 3.5 euros. \u{201C}Really?\u{201D} (Twice!) Yes\u{2026} fine.\nNext one. Last"; + assert_eq!(sentence(s, "costs"), "It costs 3.5 euros. "); + assert_eq!(sentence(s, "Really"), "\u{201C}Really?\u{201D} "); + assert_eq!(sentence(s, "Twice"), "(Twice!) "); + assert_eq!(sentence(s, "Yes"), "Yes\u{2026} "); + assert_eq!(sentence(s, "fine"), "fine."); + assert_eq!(sentence(s, "one"), "Next one. "); + assert_eq!(sentence(s, "Last"), "Last"); + // The caret at the very end of a paragraph is in its last sentence. + let end = s.find('\n').unwrap(); + assert_eq!(sentence_bounds(s, end), (s.find("fine").unwrap(), end)); + } + + #[test] + fn cjk_full_stops_end_sentences_without_a_space() { + let s = "これは文です。次の文!最後"; + assert_eq!(sentence(s, "次"), "次の文!"); + assert_eq!(sentence(s, "最後"), "最後"); + } + + #[test] + fn paragraphs_stop_at_returns() { + let s = "One.\nTwo two.\n"; + assert_eq!(para_bounds(s, 6), (5, 13)); + assert_eq!(para_bounds(s, 0), (0, 4)); + assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); + } +} diff --git a/crates/engine/src/cmd/text/edit.rs b/crates/engine/src/cmd/text/edit.rs new file mode 100644 index 000000000..fc3f5dddf --- /dev/null +++ b/crates/engine/src/cmd/text/edit.rs @@ -0,0 +1,730 @@ +use super::caret::{next_char, next_word, prev_char, prev_word, story_of}; +use super::format::{apply_typing_format, format_targets, typing_format}; +use super::{Result, Session, TextSel, Value, bad, bool_or, compose, floor_char_boundary, id_param, json, ok, str_param}; +#[cfg(test)] +use designcraft_doc::StoryId; + +// Each command edits a copy-on-write Story snapshot. Reserving its known final text growth +// avoids retaining geometric String spare capacity in every independent undo snapshot. +// General Story insertion keeps its existing amortized-growth policy for mutable builders. +fn reserve_command_text(st: &mut designcraft_doc::Story, additional: usize) -> Result<()> { + st.text.try_reserve_exact(additional).map_err(|e| crate::EngineError::Other(format!("could not reserve text insertion: {e}"))) +} + +pub(super) fn insert(s: &mut Session, p: &Value) -> Result { + let text = crate::cmd::text_param(p, "text"); + // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). + if text == "\t" && s.doc()?.selection.text.is_some_and(|t| t.cell.is_some()) { + return crate::cmd::table::step_cell(s, true); + } + let raw = p.get("raw").and_then(Value::as_bool).unwrap_or(false); + let text = if s.prefs.typographers_quotes && !raw { smart_quotes(s, &text) } else { text }; + let tracking = s.doc()?.doc.settings.track_changes; + let autocorrect = (s.prefs.autocorrect && !tracking && !raw).then(|| s.prefs.autocorrect_list.clone()); + let typing = typing_format(s).cloned(); + let result = s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.insert", "no insertion point"))?; + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let r = t.range(); + let mut r = r.start.min(st.len())..r.end.min(st.len()); + // Between a break character and its return is the end of that paragraph: typing there goes + // to the start of the next one. + if r.is_empty() + && st.text.get(r.start..).is_some_and(|t| t.starts_with('\n')) + && st.text.get(..r.start).and_then(|t| t.chars().next_back()).is_some_and(designcraft_doc::is_break_char) + { + r = r.start + 1..r.start + 1; + } + // Autocorrect: a word ended by a space or punctuation is looked up. + if let Some(list) = &autocorrect + && r.is_empty() + && text.chars().count() == 1 + && text.chars().all(|c| c.is_whitespace() || c.is_ascii_punctuation()) + { + let before = &st.text[..r.start]; + let ws = before.char_indices().rev().take_while(|(_, c)| c.is_alphabetic()).last().map(|(i, _)| i); + if let Some(ws) = ws { + let word = &before[ws..]; + if let Some((_, to)) = list.iter().find(|(from, _)| from.eq_ignore_ascii_case(word)) { + let to = if word.chars().next().is_some_and(char::is_uppercase) { + let mut c = to.chars(); + c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default() + } else { + to.clone() + }; + st.replace(ws..r.start, &to); + r = ws + to.len()..ws + to.len(); + } + } + } + // A column, frame or page break ends its paragraph. + let mut text = if text.contains(designcraft_doc::is_break_char) { + designcraft_doc::end_paragraphs_at_breaks(&text, st.text.get(r.end..).and_then(|t| t.chars().next())) + } else { + text.clone() + }; + // Typing next to a table anchor starts a paragraph of its own. + let mut trail = 0; + if t.cell.is_none() && !text.is_empty() && st.table_at(r.start).is_some() { + let pi = st.para_at(r.start); + let pr = st.para_ranges()[pi].clone(); + let anchor_at = pr.start + st.text[pr.clone()].find(designcraft_doc::TABLE_ANCHOR).unwrap_or(0); + if r.start <= anchor_at { + if !text.ends_with('\n') { + text.push('\n'); + trail = 1; + } + } else if !text.starts_with('\n') { + text.insert(0, '\n'); + } + } + if tracking && t.cell.is_none() { + // Track Changes: the replaced text is marked deleted, the typing inserted after it. + let at = crate::cmd::changes::mark_deleted(st, r.clone()); + reserve_command_text(st, text.len())?; + let mut fmt = st.insert_format_at(at).clone(); + if let Some(f) = &typing { + apply_typing_format(&mut fmt, f); + } + fmt.over.change = Some(designcraft_doc::ChangeMark::Inserted); + st.insert_with(at, &text, fmt); + let pos = at + text.len() - trail; + sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + return Ok(json!({"pos": pos})); + } + let a = floor_char_boundary(&st.text, r.start.min(st.len())); + let b = floor_char_boundary(&st.text, r.end.min(st.len())).max(a); + reserve_command_text(st, text.len().saturating_sub(b - a))?; + st.replace(r.clone(), &text); + if let Some(f) = &typing + && !text.is_empty() + { + st.format_chars(r.start..r.start + text.len(), |fmt| apply_typing_format(fmt, f)); + } + let pos = r.start + text.len() - trail; + sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + Ok(json!({"pos": pos})) + }); + // A preview (IME marked text) leaves the typing format to the text typed in the end. + if result.is_ok() && s.active().is_none_or(|d| d.interaction.is_none()) { + s.typing_format = None; + } + result +} + +/// The input method's delete-surrounding: remove `before`/`after` characters (or bytes) around +/// the selection, keeping the selection itself and its direction, then optionally type `commit` +/// over it. One undo step for the whole batch, through the normal command pipeline (preview +/// restoration, panic guard, undo, auto-size and the journal), unlike a private edit. +pub(super) fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let count = |key: &str| -> Result { + match p.get(key).and_then(Value::as_u64) { + Some(v) => usize::try_from(v).map_err(|_| bad("text.deleteSurrounding", format!("{key} is out of range"))), + None => Ok(0), + } + }; + let before = count("before")?; + let after = count("after")?; + let bytes = p.get("bytes").and_then(Value::as_bool).unwrap_or(false); + let commit = designcraft_tools::ime_commit_text(p.get("commit").and_then(Value::as_str).unwrap_or("")); + // What the request removes, from the committed text as it is now: the ranges exclude the + // selection and any pre-edit (a pre-edit is only a preview, never in the story). + let (before_range, after_range, tracking, length_before) = { + let st = s.doc()?; + let t = st.selection.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; + let story = st.doc.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let (before_range, after_range) = crate::ime_delete_ranges(&story.text, t.anchor, t.focus, before, after, bytes) + .ok_or_else(|| bad("text.deleteSurrounding", "the IME text selection is not on character boundaries"))?; + (before_range, after_range, st.doc.settings.track_changes && t.cell.is_none(), story.text.len()) + }; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.deleteSurrounding", "no text insertion point"))?; + let story = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + if !after_range.is_empty() { + if tracking { + crate::cmd::changes::mark_deleted(story, after_range.clone()); + } else { + story.delete(after_range.clone()); + } + } + let mut shift = 0usize; + if !before_range.is_empty() { + if tracking { + // Tracked changes keep the text and mark it deleted: the selection does not move. + let new_end = crate::cmd::changes::mark_deleted(story, before_range.clone()); + shift = before_range.end.saturating_sub(new_end); + } else { + shift = before_range.len(); + story.delete(before_range.clone()); + } + } + sel.text = Some(TextSel { anchor: t.anchor.saturating_sub(shift), focus: t.focus.saturating_sub(shift), ..t }); + Ok(()) + })?; + if !commit.is_empty() { + insert(s, &json!({"text": commit}))?; + } + let st = s.doc()?; + let (anchor, focus) = st.selection.text.map_or((0, 0), |t| (t.anchor, t.focus)); + let length_after = st.selection.text.and_then(|t| st.doc.text_story(t.story, t.cell)).map_or(0, |story| story.text.len()); + Ok(json!({ + "deletedBefore": [before_range.start, before_range.end], + "deletedAfter": [after_range.start, after_range.end], + "lengthBefore": length_before, + "lengthAfter": length_after, + "anchor": anchor, + "focus": focus, + })) +} + +fn smart_quotes(s: &Session, text: &str) -> String { + if !text.contains(['"', '\'']) { + return text.to_string(); + } + let prev = s + .active() + .and_then(|st| { + let t = st.selection.text?; + let story = st.doc.text_story(t.story, t.cell)?; + story.text[..t.range().start.min(story.len())].chars().last() + }) + .unwrap_or(' '); + // The language at the insertion point picks the quote marks. + let language = s + .active() + .and_then(|st| { + let t = st.selection.text?; + let story = st.doc.text_story(t.story, t.cell)?; + let pos = t.range().start.min(story.len()); + let pi = story.para_at(pos).min(story.paras.len().saturating_sub(1)); + let base = st.doc.styles.resolve_para(story.paras.get(pi)?).1; + Some(st.doc.styles.resolve_char(&base, story.char_format_at(pos)).language) + }) + .unwrap_or_default(); + let [dq_open, dq_close, sq_open, sq_close] = quote_marks(&language); + let mut out = String::with_capacity(text.len()); + let mut last = prev; + for c in text.chars() { + let open = last.is_whitespace() || matches!(last, '(' | '[' | '{' | '\u{2014}' | '\u{2013}'); + let r = match c { + '"' => { + if open { + dq_open + } else { + dq_close + } + } + '\'' => { + if open { + sq_open + } else { + // An apostrophe inside a word stays an apostrophe. + if last.is_alphanumeric() { '\u{2019}' } else { sq_close } + } + } + c => c, + }; + out.push(r); + last = c; + } + out +} + +/// Typographer's quotes of a language (a name or locale code, see +/// [`designcraft_doc::language_tag`]): [double open, double close, single open, single close]. +pub fn quote_marks(language: &str) -> [char; 4] { + const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; + let Some(tag) = designcraft_doc::language_tag(language) else { return ENGLISH }; + // Swiss German, French and Italian use guillemets. + if tag.ends_with("-CH") { + return ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; + } + match tag { + "zh-Hans" => return ENGLISH, + "zh" | "zh-Hant" => return ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], + _ => {} + } + match designcraft_doc::language_subtag(tag) { + "de" | "cs" | "sk" | "bg" | "lt" => ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}'], + "fr" | "ru" | "uk" | "no" | "nb" | "nn" | "el" => ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}'], + "es" | "it" | "pt" | "ca" => ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}'], + "nl" | "pl" | "ro" | "hu" | "hr" => ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}'], + "sv" | "fi" => ['\u{201D}', '\u{201D}', '\u{2019}', '\u{2019}'], + "da" => ['\u{00BB}', '\u{00AB}', '\u{203A}', '\u{2039}'], + "ja" => ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}'], + _ => ENGLISH, + } +} + +pub(crate) fn delete_selection(s: &mut Session) -> Result { + let tracking = s.doc()?.doc.settings.track_changes; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; + let r = t.range(); + if let Some(st) = d.text_story_mut(t.story, t.cell) { + if tracking && t.cell.is_none() { + crate::cmd::changes::mark_deleted(st, r.clone()); + } else { + st.delete(r.clone()); + } + } + sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); + ok() + }) +} + +pub(super) fn delete(s: &mut Session, p: &Value) -> Result { + let forward = bool_or(p, "forward", false); + let word = bool_or(p, "word", false); + let tracking = s.doc()?.doc.settings.track_changes; + s.edit(|d, sel| { + let t = sel.text.ok_or_else(|| bad("text.delete", "no text"))?; + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let r = t.range(); + let r = if !r.is_empty() { + r + } else if forward { + let end = if word { next_word(&st.text, r.start) } else { next_char(&st.text, r.start) }; + r.start..end + } else { + let start = if word { prev_word(&st.text, r.start) } else { prev_char(&st.text, r.start) }; + start..r.start + }; + let r = with_break_pair(&st.text, r); + if tracking && t.cell.is_none() { + // Marked, not removed: the caret steps over the deleted text. + let at = crate::cmd::changes::mark_deleted(st, r.clone()); + let pos = if forward { at.max(r.end.min(st.len())) } else { r.start.min(at) }; + sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); + return ok(); + } + st.delete(r.clone()); + sel.text = Some(TextSel { anchor: r.start, focus: r.start, ..t }); + ok() + }) +} + +/// A break character and the return after it end one paragraph: deleting either deletes both. +fn with_break_pair(text: &str, r: std::ops::Range) -> std::ops::Range { + let Some(del) = text.get(r.clone()) else { return r }; + let mut chars = del.chars(); + match (chars.next(), chars.next()) { + (Some('\n'), None) => match text.get(..r.start).and_then(|t| t.chars().next_back()) { + Some(b) if designcraft_doc::is_break_char(b) => r.start.saturating_sub(b.len_utf8())..r.end, + _ => r, + }, + (Some(b), None) if designcraft_doc::is_break_char(b) && text.get(r.end..).is_some_and(|t| t.starts_with('\n')) => r.start..r.end + 1, + _ => r, + } +} + +/// Our own filler text (not Adobe's). +pub const PLACEHOLDER: &str = "Ovid ellum quisque arcet velut tempora sint, ne veriora pareant laudemque fieri. Lorem novum strata ponet amicos, \ +et tamen uti ferrum caelo tempus mollit. Quisque sinat oppida retro, nec tenuere longas animi semper vias. \ +Sic erat, ut nostris ceperunt pectora verbis, mitescunt flamma solidos campos venti. Moventur iam nubes, aequora pontus, \ +dum sidera fulgent tacito per inane meatu. Arbore sub magna ludunt pueri, linquunt aurea litora fluctus. \ +Iamque tibi rursus ponet nova carmina vates, et longas umbras quaerit sub tegmine fagi. Omnia mutantur, nihil interit."; + +pub(super) fn fill_placeholder(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let frame = id_param(p, "frame").or_else(|| st.selection.items.first().copied()).or_else(|| st.selection.text.and_then(|t| t.frame)); + let sid = match frame.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()) { + Some(t) => t.story, + None => { + if let Some(f) = frame { + s.execute("object.content", &json!({"ids": [f.0], "type": "text"}))?; + } + story_of(s, p).ok_or_else(|| bad("type.fillWithPlaceholder", "select a text frame"))? + } + }; + // Fill until overset with paragraphs of two to five sentences, as InDesign's placeholder text + // comes (so a paragraph can be styled on its own). + const SENTENCES_PER_PARAGRAPH: [usize; 7] = [2, 2, 3, 3, 5, 2, 4]; + let mut text = String::new(); + let sentences: Vec<&str> = PLACEHOLDER.split_inclusive(". ").map(str::trim_end).collect(); + let doc = s.doc()?.doc.clone(); + let mut d2 = (*doc).clone(); + let (mut paragraph, mut in_paragraph) = (0, 0); + for i in 0..400 { + text.push_str(sentences[i % sentences.len()]); + in_paragraph += 1; + if in_paragraph >= SENTENCES_PER_PARAGRAPH[paragraph % SENTENCES_PER_PARAGRAPH.len()] { + text.push('\n'); + (paragraph, in_paragraph) = (paragraph + 1, 0); + } else { + text.push(' '); + } + if i % 3 == 2 { + d2.set_story_text(sid, &text)?; + let cs = compose::compose_story(&d2, sid, &compose::ComposeOptions::default()); + if cs.is_overset() { + // Trim to the last sentence that fits. + let cut = cs.overset_at.unwrap_or(text.len()).min(text.len()); + let keep = text.get(..cut).and_then(|t| t.rfind('.')).map(|k| k + 1).unwrap_or(cut); + text.truncate(keep); + break; + } + } + } + s.edit(|d, _| { + d.set_story_text(sid, text.trim_end())?; + ok() + }) +} + +/// The case-changed text of `src`, char by char (a char may map to several, e.g. ß → SS). +pub(crate) fn case_map(src: &str, case: &str) -> Vec { + let mut out = Vec::with_capacity(src.len()); + let mut prev = ' '; + let mut sentence_start = true; + for c in src.chars() { + let word_start = !(prev.is_alphanumeric() || matches!(prev, '\'' | '\u{2019}')); + let up = |c: char| c.to_uppercase().collect::(); + let low = |c: char| c.to_lowercase().collect::(); + // Title and sentence case capitalize only scripts with initial capitals (not Georgian). + let cap = |c: char| if designcraft_doc::capitalizes(c) { up(c) } else { low(c) }; + let m = match case { + "upper" => up(c), + "lower" => low(c), + "title" => { + if word_start { + cap(c) + } else { + low(c) + } + } + _ => { + if sentence_start && c.is_alphanumeric() { + cap(c) + } else { + low(c) + } + } + }; + if c.is_alphanumeric() { + sentence_start = false; + } else if matches!(c, '.' | '!' | '?' | '\n' | '\u{2028}') { + sentence_start = true; + } + out.push(m); + prev = c; + } + out +} + +pub(super) fn change_case(s: &mut Session, p: &Value) -> Result { + let case = str_param(p, "case").unwrap_or("upper").to_string(); + if !matches!(case.as_str(), "upper" | "lower" | "title" | "sentence") { + return Err(bad("type.changeCase", format!("unknown case `{case}`"))); + } + let targets = format_targets(s); + s.edit(|d, sel| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let r = t.range.start.min(st.len())..t.range.end.min(st.len()); + let mapped = case_map(st.slice(r.clone()), &case); + // Replace run by run (from the end) so every run keeps its formatting. + let segs: Vec> = + st.runs().map(|(rr, _)| rr.start.max(r.start)..rr.end.min(r.end)).filter(|x| x.start < x.end).collect(); + let starts: Vec = st.slice(r.clone()).char_indices().map(|(i, _)| r.start + i).collect(); + let mut delta: isize = 0; + for seg in segs.iter().rev() { + let old = st.slice(seg.clone()).to_string(); + let new: String = starts.iter().zip(&mapped).filter(|(b, _)| seg.contains(b)).map(|(_, m)| m.as_str()).collect(); + if new != old { + delta += new.len() as isize - old.len() as isize; + st.replace(seg.clone(), &new); + } + } + if let Some(ts) = sel.text.as_mut().filter(|ts| ts.story == t.story && ts.cell == t.cell && !ts.is_caret()) { + let end = (r.end as isize + delta).max(r.start as isize) as usize; + if ts.anchor <= ts.focus { + ts.focus = end; + } else { + ts.anchor = end; + } + } + } + Ok(Value::Null) + }) +} + +#[cfg(test)] +mod autocorrect_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn autocorrect_fixes_the_word_before_a_space() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let text = |s: &Session| s.doc().unwrap().doc.stories[&sid].text.clone(); + // Off by default. + for c in ["t", "e", "h", " "] { + s.execute("text.insert", &json!({"text": c})).unwrap(); + } + assert_eq!(text(&s), "teh "); + s.execute("prefs.set", &json!({"autocorrect": true})).unwrap(); + for c in ["T", "e", "h", " ", "c", "a", "t", "."] { + s.execute("text.insert", &json!({"text": c})).unwrap(); + } + assert_eq!(text(&s), "teh The cat."); + // The caret ends after the inserted punctuation. + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "teh The cat.".len()); + } +} + +#[cfg(test)] +mod break_tests { + use serde_json::json; + + use crate::Session; + + /// Type › Insert Break Character: the break ends its paragraph, the caret starts the next one. + #[test] + fn inserted_break_ends_its_paragraph() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "OneTwo\nThree"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let story = |s: &Session| s.doc().unwrap().doc.stories[&sid].clone(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{e011}", "raw": true})).unwrap(); + assert_eq!(story(&s).text, "One\u{e011}\nTwo\nThree"); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, "One\u{e011}\n".len()); + // At the end of a paragraph the break takes the place of its return. + let at = story(&s).text.find("\nThree").unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{e002}", "raw": true})).unwrap(); + let st = story(&s); + assert_eq!(st.text, "One\u{e011}\nTwo\u{e002}\nThree"); + assert_eq!(st.paras.len(), 3); + st.check().unwrap(); + // Typing after the break goes to the next paragraph; deleting the break or its return + // removes both. + let at = "One\u{e011}".len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": at, "focus": at})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(story(&s).text, "One\u{e011}\nxTwo\u{e002}\nThree"); + s.execute("text.select", &json!({"story": sid.0, "anchor": at + 1, "focus": at + 1})).unwrap(); + s.execute("text.delete", &json!({})).unwrap(); + assert_eq!(story(&s).text, "OnexTwo\u{e002}\nThree"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "OnexTwo".len(), "focus": "OnexTwo".len()})).unwrap(); + s.execute("text.delete", &json!({"forward": true})).unwrap(); + assert_eq!(story(&s).text, "OnexTwoThree"); + } +} + +#[cfg(test)] +mod language_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn language_sets_quotes_spelling_and_hyphenation() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("type.char", &json!({"attrs": {"language": "German: 2006 Reform"}})).unwrap(); + s.execute("text.insert", &json!({"text": "\"Wort\" ist's"})).unwrap(); + let text = s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].text.clone(); + assert_eq!(text, "\u{201E}Wort\u{201C} ist\u{2019}s"); + // German words aren't English misspellings. + let st = s.execute("spelling.check", &json!({"story": sid})).unwrap(); + assert!(st.as_array().unwrap().is_empty(), "{st}"); + } + + #[test] + fn cjk_languages_have_their_quotes_and_no_english_rules() { + use super::quote_marks; + assert_eq!(quote_marks("Chinese: Simplified"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("Chinese: Traditional"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("Chinese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("Japanese"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + // Other spellings of the language names take the same quotes. + assert_eq!(quote_marks("Simplified Chinese"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("zh_CN"), ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']); + assert_eq!(quote_marks("Chinese (Traditional)"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("zh-TW"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + assert_eq!(quote_marks("ja_JP"), ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']); + for l in ["Japanese", "Korean", "Chinese", "Chinese: Simplified", "Chinese: Traditional"] { + assert!(!designcraft_compose::is_english(l), "{l}: no English hyphenation or spelling"); + } + } + + #[test] + fn locale_coded_languages_take_their_quotes_and_rules() { + use super::quote_marks; + const GERMAN: [char; 4] = ['\u{201E}', '\u{201C}', '\u{201A}', '\u{2018}']; + const DUTCH: [char; 4] = ['\u{201E}', '\u{201D}', '\u{201A}', '\u{2019}']; + const GUILLEMETS: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{2039}', '\u{203A}']; + const SPANISH: [char; 4] = ['\u{00AB}', '\u{00BB}', '\u{201C}', '\u{201D}']; + const ENGLISH: [char; 4] = ['\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}']; + const CORNERS: [char; 4] = ['\u{300C}', '\u{300D}', '\u{300E}', '\u{300F}']; + for (language, quotes, english) in [ + ("de_DE_2006", GERMAN, false), + ("German: 2006 Reform", GERMAN, false), + ("de_CH_2006", GUILLEMETS, false), + ("German: Swiss 2006 Reform", GUILLEMETS, false), + ("nl_NL_2005", DUTCH, false), + ("Dutch: 2005 Reform", DUTCH, false), + ("English: USA", ENGLISH, true), + ("English: UK", ENGLISH, true), + ("en_GB", ENGLISH, true), + ("Spanish: Castilian", SPANISH, false), + ("es_ES", SPANISH, false), + ("French", GUILLEMETS, false), + ("fr_FR", GUILLEMETS, false), + ("nb_NO", GUILLEMETS, false), + ("ja_JP", CORNERS, false), + ("[No Language]", ENGLISH, false), + ] { + assert_eq!(quote_marks(language), quotes, "{language}"); + assert_eq!(designcraft_compose::is_english(language), english, "{language}"); + } + } + + #[test] + fn idml_language_names_keep_their_spelling_and_pick_the_language() { + use std::io::Read; + // A stand-in for Songti SC (first in the Simplified Chinese chain; the real one when installed). + designcraft_fonts::FontDb::global().add_font(designcraft_fonts::testing::font_with("Songti SC", &['直']).unwrap()); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "直"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap(); + s.execute("type.char", &json!({"attrs": {"language": "Simplified Chinese"}})).unwrap(); + let idml = designcraft_idml::export_idml(&s.doc().unwrap().doc); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(idml.clone())).unwrap(); + let stories: String = (0..z.len()) + .map(|i| { + let mut f = z.by_index(i).unwrap(); + let mut x = String::new(); + if f.name().starts_with("Stories/") { + f.read_to_string(&mut x).unwrap(); + } + x + }) + .collect(); + assert!(stories.contains(r#"AppliedLanguage="$ID/Simplified Chinese""#), "{stories}"); + // The name comes back as written and sets the text as Simplified Chinese. + let mut back = designcraft_idml::import_idml(&idml).unwrap(); + back.settings.glyph_fallback = true; + let st = back.stories.values().find(|st| st.text.contains('直')).unwrap(); + let (_, base) = back.styles.resolve_para(&st.paras[0]); + assert_eq!(back.styles.resolve_char(&base, st.char_format_at(0)).language, "Simplified Chinese"); + let cs = designcraft_compose::compose_story(&back, st.id, &Default::default()); + let face = cs.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 0).unwrap().face; + assert_eq!(face.family, "Songti SC"); + } +} + +#[cfg(test)] +mod arabic_tests { + use super::*; + #[test] + fn arabic_story_direction_command_validates_and_supports_undo() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "abc"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let before = s.cache.get(&s.doc().unwrap().doc, sid, None); + s.execute("story.setDirection", &json!({"story": sid.0, "direction": "rightToLeft"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::RightToLeft); + let after = s.cache.get(&s.doc().unwrap().doc, sid, None); + assert!(!std::sync::Arc::ptr_eq(&before, &after)); + assert!(s.execute("story.setDirection", &json!({"story": sid.0, "direction": "unknown"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight); + } +} + +#[cfg(test)] +mod korean_breaks_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn korean_character_breaks_are_a_paragraph_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "한국어 문장"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("type.para", &json!({"attrs": {"koreanCharBreaks": true}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.korean_char_breaks, Some(true)); + s.execute("style.paragraph.create", &json!({"name": "Korean", "para": {"koreanCharBreaks": true}})).unwrap(); + assert_eq!(s.doc().unwrap().doc.styles.para("Korean").unwrap().para.korean_char_breaks, Some(true)); + } +} + +#[cfg(test)] +mod reserve_tests { + use super::*; + + #[test] + fn failed_reservation_rolls_back_the_edit_even_after_local_text_was_removed() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let result = s.execute("frame.create", &json!({"rect":[36,36,300,300],"content":"text","text":"Keep this text."})).unwrap(); + let sid = StoryId(result["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story":sid.0,"anchor":1,"focus":4})).unwrap(); + let before = s.doc().unwrap().clone(); + assert!(before.selection.text.is_some_and(|t| !t.range().is_empty())); + let journal = s.journal.clone(); + let error = s + .edit(|d, sel| { + let st = d.story_mut(sid).unwrap(); + st.format_chars(5..9, |f| f.over.change = Some(designcraft_doc::ChangeMark::Inserted)); + let at = crate::cmd::changes::mark_deleted(st, 0..9); + assert_eq!(at, 5); + assert_eq!(st.text, "Keep text."); + assert_eq!(st.format_after(0).over.change, Some(designcraft_doc::ChangeMark::Deleted)); + sel.text = None; + reserve_command_text(st, usize::MAX)?; + ok() + }) + .unwrap_err(); + assert!(error.to_string().contains("could not reserve text insertion")); + let after = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&after.doc, &before.doc)); + assert_eq!(after.selection, before.selection); + assert_eq!(after.revision, before.revision); + assert_eq!(after.history.undo.len(), before.history.undo.len()); + assert_eq!(after.history.redo.len(), before.history.redo.len()); + assert_eq!(s.journal, journal); + } +} + +#[cfg(test)] +mod placeholder_tests { + use serde_json::json; + + use crate::Session; + + /// Fill with Placeholder Text gives paragraphs of a few sentences, as InDesign's does (it was one + /// paragraph, and the filler ran sentences together: "interit.Ovid"). + #[test] + fn placeholder_text_comes_in_paragraphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 336, 736], "content": "text", "text": ""})).unwrap(); + s.execute("type.fillWithPlaceholder", &json!({"frame": r["id"]})).unwrap(); + let st = s.execute("story.get", &json!({"story": r["story"]})).unwrap(); + let text = st["text"].as_str().unwrap(); + let paragraphs: Vec<&str> = text.split('\n').collect(); + assert!(paragraphs.len() >= 4, "{} paragraphs", paragraphs.len()); + for p in ¶graphs { + assert!(p.ends_with('.') && !p.starts_with(' '), "a paragraph of whole sentences: {p:?}"); + } + assert!(!text.contains(".O") && !text.contains(".L"), "sentences are spaced: {text}"); + assert!(st["overset"].is_null(), "nothing overset: {}", st["overset"]); + assert!(text.len() > 1500, "the frame is filled: {}", text.len()); + } +} diff --git a/crates/engine/src/cmd/text/format.rs b/crates/engine/src/cmd/text/format.rs new file mode 100644 index 000000000..7e1833c0a --- /dev/null +++ b/crates/engine/src/cmd/text/format.rs @@ -0,0 +1,1075 @@ +use super::asian::bool_param; +use super::caret::hit_byte_with; +use super::story::selection_attrs; +use super::{ + CellAddr, CharAttrs, ParaAttrs, Result, Session, StoryId, TextSel, Value, bad, compose, floor_char_boundary, id_param, json, ok, point_param, +}; + +/// The Eyedropper with text selected: the formatting of the text under `point` in `frame` goes to +/// the selection, as InDesign's does (character style and overrides on the selected characters, +/// paragraph style and overrides on their paragraphs). +pub(super) fn pick_up_format(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.pickUpFormat"; + let frame = id_param(p, "frame").ok_or_else(|| bad(ID, "missing frame"))?; + let pt = point_param(p, "point").ok_or_else(|| bad(ID, "missing point"))?; + let (sid, b, cell) = hit_byte_with(s, frame, pt, compose::hit_char).ok_or_else(|| bad(ID, "not a text frame"))?; + let st = s.doc()?; + let sel = st.selection.text.ok_or_else(|| bad(ID, "select the text to format"))?; + let src = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let b = floor_char_boundary(&src.text, b); + // The character under the pointer (at the end of a line, the one before it). + let at = if b >= src.text.len() || src.text[b..].starts_with('\n') { b.saturating_sub(1) } else { b }; + let at = floor_char_boundary(&src.text, at); + let chars = src.char_format_at(at).clone(); + let para = src.paras.get(src.para_at(at)).cloned().ok_or(designcraft_doc::DocError::NoStory(sid))?; + let (a, e) = (sel.anchor.min(sel.focus), sel.anchor.max(sel.focus)); + s.typing_format = None; + s.edit(|d, _| { + let story = d.text_story_mut(sel.story, sel.cell).ok_or(designcraft_doc::DocError::NoStory(sel.story))?; + if e > a { + story.format_chars(a..e, |f| *f = chars.clone()); + } + story.format_paras(a..e, |f| { + f.style = para.style.clone(); + f.para = para.para.clone(); + }); + Ok(json!({"style": chars.style, "paragraphStyle": para.style})) + }) +} + +/// Where a formatting command applies: a story (or a table cell's story) and a byte range. +#[derive(Clone, Debug)] +pub(crate) struct Target { + pub story: StoryId, + pub cell: Option, + pub range: std::ops::Range, +} + +/// Ranges and stories a formatting command applies to: selected table cells (whole cells), the +/// text selection, or whole stories of the selected text frames. +pub(crate) fn format_targets(s: &Session) -> Vec { + let Some(st) = s.active() else { return vec![] }; + if let Some(ts) = st.selection.cells + && let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table)) + { + let mut v = Vec::new(); + for (r, c) in t.selected_owners(ts.range) { + let len = t.cell(r, c).map_or(0, |x| x.text.len()); + v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len }); + } + return v; + } + if let Some(t) = st.selection.text { + return vec![Target { story: t.story, cell: t.cell, range: t.range() }]; + } + let mut v: Vec = Vec::new(); + for id in &st.selection.items { + if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) { + let len = st.doc.story(tf.story).map(|x| x.len()).unwrap_or(0); + if !v.iter().any(|t| t.story == tf.story) { + v.push(Target { story: tf.story, cell: None, range: 0..len }); + } + } + } + v +} + +/// Character formatting chosen at a bare caret in text: applied to what is typed there next. +#[derive(Clone, Debug)] +pub(crate) struct TypingFormat { + doc: u64, + at: TextSel, + attrs: CharAttrs, + /// Overrides removed (`null` in `type.char`). + cleared: Vec, +} + +/// The typing format for the active document's caret, if it was chosen at this caret. +pub(super) fn typing_format(s: &Session) -> Option<&TypingFormat> { + let st = s.active()?; + let t = st.selection.text?; + s.typing_format.as_ref().filter(|f| f.doc == st.uid && f.at.story == t.story && f.at.cell == t.cell && f.at.range() == t.range()) +} + +/// Is `flag` on in the character format at the start of the first formatting target? +pub(super) fn char_flag_on(s: &Session, flag: impl Fn(&CharAttrs) -> Option) -> bool { + format_targets(s).first().is_some_and(|t| { + s.doc() + .ok() + .and_then(|d| d.doc.text_story(t.story, t.cell)) + .is_some_and(|st| flag(&st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over) == Some(true)) + }) +} + +/// Switch a paragraph attribute of the selected paragraphs: `on`, else the opposite of the +/// first one's. +pub(super) fn para_toggle(s: &mut Session, p: &Value, cmd: &str, attr: &str, get: impl Fn(&designcraft_doc::ParaProps) -> bool) -> Result { + let on = match bool_param(p, "on", cmd)? { + Some(v) => v, + None => !format_targets(s).first().is_some_and(|t| { + s.doc().ok().is_some_and(|d| { + d.doc + .text_story(t.story, t.cell) + .and_then(|st| st.paras.get(st.para_at(t.range.start))) + .is_some_and(|pf| get(&d.doc.styles.resolve_para(pf).0)) + }) + }), + }; + let mut body = serde_json::Map::new(); + body.insert(attr.to_string(), json!(on)); + format_paras(s, &Value::Object(body)) +} + +/// A character fill must name a swatch of the document. +pub(crate) fn validate_char_fill(document: &designcraft_doc::Document, attrs: &CharAttrs, command_id: &str) -> Result<()> { + validate_char_size(attrs, command_id)?; + match &attrs.fill { + Some(fill) => crate::cmd::object::known_swatch(document, fill, command_id, "fill"), + None => Ok(()), + } +} + +/// Type size is 0.1 to 1296 pt, the range this engine already applies to a ruby annotation's +/// size. Nothing checked it for ordinary text, so `{"size": 0}` set type with no size, a negative +/// size was accepted as well, and 1e9 pt asked the composer for a line a million times the page. +pub(crate) fn validate_char_size(attrs: &CharAttrs, command_id: &str) -> Result<()> { + match attrs.size { + Some(size) if !(MIN_TYPE_SIZE..=MAX_TYPE_SIZE).contains(&size) => { + Err(bad(command_id, format!("size {size}: {MIN_TYPE_SIZE} to {MAX_TYPE_SIZE} pt"))) + } + _ => Ok(()), + } +} + +/// Type size limits (InDesign's, and the ones `type.ruby` already uses). +pub(crate) const MIN_TYPE_SIZE: f64 = 0.1; + +pub(crate) const MAX_TYPE_SIZE: f64 = 1296.0; + +/// Byte `pos` is the hidden paragraph mark of an *empty* paragraph: the only character on that +/// line, and so the one a line-affecting attribute like leading can land on right away instead of +/// waiting for the next typed character (otherwise an empty line could never get its own leading). +fn empty_para_mark(story: &designcraft_doc::Story, pos: usize) -> bool { + let ranges = story.para_ranges(); + ranges.get(story.para_at(pos)).is_some_and(|r| r.start == pos && r.end == pos) && story.text.as_bytes().get(pos) == Some(&b'\n') +} + +pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { + let mut a = CharAttrs::default(); + if let Some(o) = attrs.as_object() { + for (k, v) in o { + a.set_json(k, v).map_err(|e| bad("type.char", e))?; + } + } + validate_char_fill(&s.doc()?.doc, &a, "type.char")?; + let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let targets = format_targets(s); + // A caret in text: the format is for the next typing. On an empty paragraph's hidden mark it + // also formats the mark right away, since the mark alone sets that line's height; typing there + // still needs the typing format, as new text takes the format of the run before the caret + // (the previous paragraph's mark). + if let [t] = targets.as_slice() + && t.range.is_empty() + && let Some(at) = s.doc()?.selection.text + && let Some(on_mark) = s.doc()?.doc.text_story(t.story, t.cell).filter(|st| !st.is_empty()).map(|st| empty_para_mark(st, t.range.start)) + { + let doc = s.doc()?.uid; + let mut f = typing_format(s).cloned().unwrap_or(TypingFormat { doc, at, attrs: CharAttrs::default(), cleared: vec![] }); + f.attrs.merge(&a); + f.cleared.retain(|k| attrs.get(k).is_none_or(|v| v.is_null()) && !cleared.contains(k)); + f.cleared.extend(cleared.iter().cloned()); + if on_mark { + s.edit(|d, _| { + apply_char_attrs(d, &targets, &a, &cleared); + ok() + })?; + } + s.typing_format = Some(f); + return ok(); + } + s.edit(|d, _| { + apply_char_attrs(d, &targets, &a, &cleared); + ok() + }) +} + +/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the +/// character before it. With no such pair (story or paragraph start, a selection) this is +/// `type.char`. +pub(super) fn kern_pair(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.kernPair"; + let kerning = p.get("kerning").ok_or_else(|| bad(ID, "missing `kerning`"))?; + let targets = format_targets(s); + if let [t] = targets.as_slice() + && t.range.is_empty() + && s.doc()?.selection.text.is_some() + && let Some(start) = s.doc()?.doc.text_story(t.story, t.cell).and_then(|st| pair_start(&st.text, t.range.start)) + { + let mut a = CharAttrs::default(); + a.set_json("kerning", kerning).map_err(|e| bad(ID, e))?; + let cleared = if kerning.is_null() { vec!["kerning".to_string()] } else { vec![] }; + let pair = Target { story: t.story, cell: t.cell, range: start..t.range.start }; + // Kerning chosen earlier for typing at this caret would hide the pair's value. + if typing_format(s).is_some() + && let Some(f) = &mut s.typing_format + { + f.attrs.kerning = None; + f.cleared.retain(|k| k != "kerning"); + } + return s.edit(|d, _| { + apply_char_attrs(d, &[pair], &a, &cleared); + ok() + }); + } + format_chars(s, &json!({ "kerning": kerning })) +} + +/// Byte offset of the character before `pos` when it starts a kerning pair with the character at +/// `pos`: both exist and neither is a paragraph or line break. +fn pair_start(text: &str, pos: usize) -> Option { + let is_text = |c: &char| !matches!(*c, '\n' | designcraft_doc::story::FORCED_LINE_BREAK); + text.get(pos..)?.chars().next().filter(is_text)?; + let c = text.get(..pos)?.chars().next_back().filter(is_text)?; + pos.checked_sub(c.len_utf8()) +} + +/// Character attributes `a` (and the overrides `cleared` removed) over each target's text. +pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { + for t in targets { + let r = &t.range; + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if r.is_empty() { + if st.is_empty() { + // An empty story: its one (empty) run is the format typing takes. + if let Some(run) = st.chars.first_mut() { + run.format.over.merge(a); + st.rev = st.rev.wrapping_add(1); + } + } else if empty_para_mark(st, r.start) { + // An empty paragraph elsewhere in the story: its hidden paragraph mark is its only + // character, and the one that carries line-affecting attributes like leading. + st.format_chars(r.start..r.start + 1, |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } + continue; + } + st.format_chars(r.clone(), |f| { + f.over.merge(a); + for k in cleared { + let _ = f.over.set_json(k, &Value::Null); + } + }); + } +} + +pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { + let empty = serde_json::Map::new(); + let obj = attrs.as_object().unwrap_or(&empty); + // A rule object changes only the rule fields it names, so each paragraph gets its own attrs + // over its resolved rule; this pass validates them. + let mut a = ParaAttrs::default(); + for (k, v) in obj { + a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; + } + crate::cmd::style::check_grep_styles("type.para", &a)?; + // `null` removes the override (back to the style's value). + let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + if let Some(st) = d.text_story_mut(t.story, t.cell) { + st.format_paras(t.range.clone(), |p| { + let (current, _) = styles.resolve_para(p); + let mut a = a.clone(); + for (k, v) in obj { + let _ = a.set_json_over(k, v, ¤t); + } + p.para.merge(&a); + for k in &cleared { + let _ = p.para.set_json(k, &Value::Null); + } + }); + } + } + ok() + }) +} + +/// Alt+Left/Right in text: kerning at a caret, tracking of a selection, by the Kerning/Tracking +/// increment. +pub(super) fn kern_step(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::Kerning; + let inc = s.doc()?.doc.settings.kerning_increment; + let inc = if inc.is_finite() { inc.clamp(1.0, 1000.0) } else { 20.0 }; + let amount = match p.get("amount").and_then(Value::as_f64) { + Some(a) => a, + None => p.get("steps").and_then(Value::as_f64).unwrap_or(1.0) * inc, + }; + if !amount.is_finite() { + return Err(bad("type.kernStep", "`amount` must be a number")); + } + let amount = amount.clamp(-11000.0, 11000.0); + // Kerning and tracking range, in 1/1000 em. + let clamp = |v: f64| v.clamp(-1000.0, 10000.0); + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + let mut out = json!({}); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if t.range.is_empty() { + // Manual kerning sits on the character before the caret (it applies after it). + let pos = t.range.start; + let Some(c) = st.text.get(..pos).and_then(|before| before.chars().next_back()) else { continue }; + let at = pos - c.len_utf8(); + let para = st.paras.get(st.para_at(at)).cloned().unwrap_or_default(); + let current = styles.resolve_char(&styles.resolve_para(¶).1, st.format_after(at)).kerning; + // The font's metrics or optical value isn't known here: those start from 0. + let v = clamp(if let Kerning::Manual(v) = current { v } else { 0.0 } + amount); + st.format_chars(at..pos, |f| f.over.kerning = Some(Kerning::Manual(v))); + out = json!({"kerning": v}); + } else { + for (i, pr) in st.para_ranges().into_iter().enumerate() { + let r = pr.start.max(t.range.start)..pr.end.min(t.range.end); + let Some(para) = st.paras.get(i).cloned() else { break }; + if r.is_empty() { + continue; + } + let pc = styles.resolve_para(¶).1; + st.format_chars(r, |f| { + let v = clamp(styles.resolve_char(&pc, f).tracking + amount); + f.over.tracking = Some(v); + out = json!({"tracking": v}); + }); + } + } + } + Ok(out) + }) +} + +pub(super) fn step_size(s: &mut Session, delta: f64) -> Result { + let cur = selection_attrs(s, &json!({}))?; + let size = cur["chars"]["size"].as_f64().unwrap_or(12.0); + format_chars(s, &json!({"size": (size + delta).clamp(0.1, 1296.0)})) +} + +pub(super) fn break_link(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::{CharFormat, CharProps, ParaProps}; + let para = match p.get("kind").and_then(Value::as_str).unwrap_or("paragraph") { + "paragraph" => true, + "character" => false, + k => return Err(bad("style.breakLink", format!("unknown kind `{k}`"))), + }; + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let r = t.range.clone(); + if para { + st.format_paras(r, |pf| { + let (pp, pc) = styles.resolve_para(pf); + pf.style = designcraft_doc::styles::NO_PARA_STYLE.into(); + pf.para = ParaProps::default().delta(&pp); + pf.chars = CharProps::default().delta(&pc); + }); + } else { + let ranges = st.para_ranges(); + for (pi, pr) in ranges.into_iter().enumerate() { + let (a, b) = (pr.start.max(r.start), pr.end.min(r.end)); + if a >= b { + continue; + } + let (_, pc) = styles.resolve_para(&st.paras[pi]); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + *f = CharFormat { style: designcraft_doc::story::NO_CHAR_STYLE.into(), over: pc.delta(&cp) }; + }); + } + } + } + ok() + }) +} + +/// The first target character, with any pending caret overrides. Keep character-style +/// inheritance separate from paragraph defaults for style creation from the selection. +pub(crate) fn effective_char_format(s: &Session, target: &Target) -> Result { + let story = s.doc()?.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let mut format = + if target.range.is_empty() { story.insert_format_at(target.range.start) } else { story.format_after(target.range.start) }.clone(); + if let Some(pending) = typing_format(s) + && target.story == pending.at.story + && target.cell == pending.at.cell + && target.range == pending.at.range() + { + apply_typing_format(&mut format, pending); + } + Ok(format) +} + +/// A swatch promoted to a named color keeps any not-yet-inserted text pointing at it. +pub(crate) fn rename_typing_swatch(s: &mut Session, from: &str, to: &str) { + let Some(uid) = s.active().map(|st| st.uid) else { return }; + if let Some(pending) = &mut s.typing_format + && pending.doc == uid + { + for name in [&mut pending.attrs.fill, &mut pending.attrs.stroke, &mut pending.attrs.underline_color, &mut pending.attrs.strikethrough_color] + .into_iter() + .flatten() + { + if name == from { + *name = to.to_string(); + } + } + } +} + +fn swapped_colors(cp: &designcraft_doc::CharProps) -> CharAttrs { + CharAttrs { + fill: Some(cp.stroke.clone()), + fill_tint: Some(cp.stroke_tint), + stroke: Some(cp.fill.clone()), + stroke_tint: Some(cp.fill_tint), + ..Default::default() + } +} + +/// Resolve each paragraph/run before editing: a mixed selection must not take the first run's colors. +pub(super) fn swap_fill_stroke(s: &mut Session, _p: &Value) -> Result { + const ID: &str = "type.swapFillStroke"; + let targets = format_targets(s); + if let [target] = targets.as_slice() + && target.range.is_empty() + && s.doc()?.selection.text.is_some() + { + let st = s.doc()?; + let story = st.doc.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + let pf = story.paras.get(story.para_at(target.range.start)).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = st.doc.styles.resolve_para(pf); + let cp = st.doc.styles.resolve_char(&base, &effective_char_format(s, target)?); + return format_chars(s, &json!({"fill": cp.stroke, "fillTint": cp.stroke_tint, "stroke": cp.fill, "strokeTint": cp.fill_tint})); + } + let mut edits = Vec::new(); + let d = &s.doc()?.doc; + for target in targets { + let story = d.text_story(target.story, target.cell).ok_or(designcraft_doc::DocError::NoStory(target.story))?; + if story.is_empty() { + let pf = story.paras.first().ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + let attrs = swapped_colors(&d.styles.resolve_char(&base, story.char_format_at(0))); + validate_char_fill(d, &attrs, ID)?; + edits.push((target, attrs)); + continue; + } + let mut runs = story.runs().peekable(); + for (pi, para) in story.para_ranges().into_iter().enumerate() { + let start = para.start.max(target.range.start); + let end = para.end.saturating_add(1).min(story.len()).min(target.range.end); + if start >= end { + continue; + } + let pf = story.paras.get(pi).ok_or_else(|| bad(ID, "missing paragraph"))?; + let (_, base) = d.styles.resolve_para(pf); + while let Some((range, format)) = runs.peek() { + if range.end <= start { + runs.next(); + continue; + } + if range.start >= end { + break; + } + let attrs = swapped_colors(&d.styles.resolve_char(&base, format)); + validate_char_fill(d, &attrs, ID)?; + edits.push((Target { range: start.max(range.start)..end.min(range.end), ..target.clone() }, attrs)); + if range.end > end { + break; + } + runs.next(); + } + } + } + s.edit(|d, _| { + for (target, attrs) in &edits { + apply_char_attrs(d, std::slice::from_ref(target), attrs, &[]); + } + ok() + }) +} + +pub(super) fn apply_typing_format(cf: &mut designcraft_doc::CharFormat, f: &TypingFormat) { + cf.over.merge(&f.attrs); + for k in &f.cleared { + let _ = cf.over.set_json(k, &Value::Null); + } +} + +/// Bold (`bold`) or italic from the font family (style linking), per run of the selection: on or +/// off as asked, else the opposite of the first selected character. +pub(super) fn link_style(s: &mut Session, p: &Value, bold: bool) -> Result { + let id = if bold { "type.bold" } else { "type.italic" }; + let cur = selection_attrs(s, &json!({}))?; + let family = cur["chars"]["fontFamily"].as_str().unwrap_or_default().to_string(); + let style = cur["chars"]["fontStyle"].as_str().unwrap_or_default().to_string(); + // The document's own fonts count too. + let db = designcraft_fonts::FontDb::global().scoped(s.doc()?.doc.font_scope); + let (weight, italic) = db.traits_of(&family, &style); + let on = p.get("on").and_then(Value::as_bool).unwrap_or(if bold { weight < 650.0 } else { !italic }); + let (want_bold, want_italic) = if bold { (Some(on), None) } else { (None, Some(on)) }; + let targets = format_targets(s); + // A caret: the linked style of the format there, for the next typing. + if let [t] = targets.as_slice() + && t.range.is_empty() + { + let linked = db.linked_style(&family, &style, want_bold, want_italic).ok_or_else(|| bad(id, format!("{family} has no style for that")))?; + format_chars(s, &json!({"fontStyle": linked}))?; + return Ok(json!({"on": on, "fontStyle": linked})); + } + let mut changed = 0; + let r = s.edit(|d, _| { + let styles = d.styles.clone(); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + for (pi, pr) in st.para_ranges().into_iter().enumerate() { + let (a, b) = (pr.start.max(t.range.start), pr.end.min(t.range.end)); + if a >= b { + continue; + } + let Some(pf) = st.paras.get(pi) else { continue }; + let (_, pc) = styles.resolve_para(pf); + st.format_chars(a..b, |f| { + let cp = styles.resolve_char(&pc, f); + if let Some(linked) = db.linked_style(&cp.font_family, &cp.font_style, want_bold, want_italic) + && linked != cp.font_style + { + f.over.font_style = Some(linked); + changed += 1; + } + }); + } + } + // No undo step for nothing. + if changed == 0 { + return Err(bad(id, "nothing to change")); + } + ok() + }); + r?; + Ok(json!({"on": on, "changed": changed})) +} + +pub(crate) fn format_targets_pub(s: &Session) -> Vec { + format_targets(s) +} + +#[cfg(test)] +mod nested_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn nested_and_grep_styles_colour_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Lead", "chars": {"fill": "C=100 M=0 Y=0 K=0"}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Num", "chars": {"fill": "C=0 M=100 Y=0 K=0"}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Opening words then 2026 here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.execute( + "type.para", + &json!({"attrs": { + "nestedStyles": [{"style": "Lead", "through": true, "count": 2, "until": {"kind": "words"}}], + "grepStyles": [{"style": "Num", "pattern": "\\d+"}] + }}), + ) + .unwrap(); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, designcraft_doc::StoryId(sid), &Default::default()); + let text = &d.story(designcraft_doc::StoryId(sid)).unwrap().text; + let fill_at = |byte: usize| { + let g = cs.frames[0].lines.iter().flat_map(|l| &l.glyphs).find(|g| g.byte == byte).unwrap(); + cs.styles[g.style as usize].fill.clone() + }; + assert_eq!(fill_at(0), "C=100 M=0 Y=0 K=0", "nested: first two words"); + assert_eq!(fill_at(text.find("words").unwrap()), "C=100 M=0 Y=0 K=0"); + assert_eq!(fill_at(text.find("then").unwrap()), "[Black]"); + assert_eq!(fill_at(text.find("2026").unwrap()), "C=0 M=100 Y=0 K=0", "GREP: digits"); + } +} + +#[cfg(test)] +mod style_link_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn bold_and_italic_follow_the_family_and_toggle() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "plain words here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + let style_at = |s: &mut Session, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": b, "focus": b + 1})).unwrap(); + s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"].as_str().unwrap().to_string() + }; + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.italic", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + // Bold over italic and plain text: each run takes its own linked style. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Bold Italic"); + assert_eq!(style_at(&mut s, 7), "Bold"); + // Again: off, back to each run's regular weight. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 11})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(style_at(&mut s, 0), "Italic"); + assert_eq!(style_at(&mut s, 7), "Regular"); + // Nothing to change: an error and no undo step. + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + assert!(s.execute("type.bold", &json!({"on": false})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // At a caret the style is for what is typed next. + let end = "plain words here".len(); + s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); + s.execute("type.bold", &json!({})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["fontStyle"], "Bold"); + s.execute("text.insert", &json!({"text": " now"})).unwrap(); + assert_eq!(style_at(&mut s, end + 1), "Bold"); + assert_eq!(style_at(&mut s, end - 1), "Regular", "text before the caret is unchanged"); + // Moving the caret forgets it. + s.execute("text.select", &json!({"story": sid, "anchor": 2, "focus": 2})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 3})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 3, "focus": 4})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 12.0); + } +} + +#[cfg(test)] +mod text_color_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn unnamed_colours_go_to_text_when_asked() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "colour me", "caret": false})).unwrap(); + let (fid, sid) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + s.execute("selection.set", &json!({"ids": [fid]})).unwrap(); + let fill_of_frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(fid)).unwrap().fill.swatch.clone(); + let before = fill_of_frame(&s); + let r = s.execute("object.color", &json!({"color": "#ff0000", "text": true})).unwrap(); + let red = r["swatch"].as_str().unwrap().to_string(); + assert_eq!(fill_of_frame(&s), before, "the frame keeps its fill"); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.char_format_at(3).over.fill.as_deref(), Some(red.as_str())); + // A text selection takes it without asking. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + let r = s.execute("object.color", &json!({"color": "#00ff00", "target": "stroke"})).unwrap(); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone(); + assert_eq!(story.format_after(0).over.stroke.as_deref(), r["swatch"].as_str()); + assert_eq!(s.doc().unwrap().history.undo.len(), 3, "the frame, then one undo step per colour"); + } +} + +#[cfg(test)] +mod nested_line_style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn first_line_takes_the_nested_line_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.character.create", &json!({"name": "Lead", "chars": {"size": 18}})).unwrap(); + let text = "The opening line of this paragraph reads larger, and the lines after it return to the body size again."; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": text})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.para", &json!({"nestedLineStyles": [{"style": "Lead", "lines": 1}]})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let lines = &cs.frames[0].lines; + assert!(lines.len() >= 3); + let size_of = |l: usize| cs.styles[lines[l].glyphs.iter().find(|g| g.visible).unwrap().style as usize].size; + assert_eq!(size_of(0), 18.0, "the first line"); + assert!( + lines[0].glyphs.iter().filter(|g| g.visible && g.adv > 0.0).all(|g| cs.styles[g.style as usize].size == 18.0), + "the whole first line" + ); + assert_eq!(size_of(1), 12.0, "the next lines"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + let st = back.stories.values().find(|st| st.text.starts_with("The opening")).unwrap(); + let nl = st.paras[0].para.nested_line_styles.clone().unwrap_or_default(); + assert_eq!((nl.len(), nl.first().map(|n| n.lines)), (1, Some(1)), "IDML AllNestedLineStyles"); + assert_eq!(nl[0].style, "Lead"); + } +} + +#[cfg(test)] +mod rule_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_partial_rule_edit_keeps_the_other_fields() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); + s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap(); + // Local: only the colour changes; the rest is inherited from the style. + s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let mut want = rule.clone(); + want["color"] = json!("[Registration]"); + assert_eq!(attrs["para"]["ruleBelow"], want); + assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched"); + // Style: only the weight changes. + s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap(); + let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap(); + let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap(); + let mut want = rule.clone(); + want["weight"] = json!(1.5); + assert_eq!(below, want); + assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field"); + } + + #[test] + fn rule_above_width_follows_column_or_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mut widths = vec![]; + for column in [true, false] { + s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap(); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + widths.push(cs.frames[0].decos[0].rect.width()); + } + assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}"); + assert!(widths[1] < 100.0, "text: {widths:?}"); + } +} + +#[cfg(test)] +mod border_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn paragraph_border_and_shading_follow_each_column() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "word ".repeat(80); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 160], "content": "text", "text": text, "caret": false})).unwrap(); + s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.para", &json!({"borderOn": true, "borderWeights": [2, 1, 2, 1], "borderOffsets": [4, 4, 4, 4], "borderColor": "[Black]", "shadingOn": true, "shadingOffsets": [4, 4, 4, 4]})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + let decos = &cs.frames[0].decos; + let shading: Vec<_> = decos.iter().filter(|d| d.tint < 0.5).collect(); + assert_eq!(shading.len(), 2, "one shaded box per column"); + let edges = decos.len() - shading.len(); + assert_eq!(edges, 6, "top on the first part, bottom on the last, sides on both"); + let top = decos.iter().filter(|d| d.tint >= 0.5).map(|d| d.rect).fold(f64::MAX, |a, r| a.min(r.y0)); + let first_line = &cs.frames[0].lines[0]; + assert!((top - (first_line.baseline - first_line.ascent - 4.0 - 2.0)).abs() < 1e-6, "offset 4 + weight 2 above the text"); + let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap(); + let p = &back.stories.values().find(|st| st.text.starts_with("word")).unwrap().paras[0].para; + assert_eq!( + (p.border_on, p.border_weights, p.shading_offsets), + (Some(true), Some([2.0, 1.0, 2.0, 1.0]), Some([4.0; 4])), + "IDML paragraph border" + ); + } +} + +#[cfg(test)] +mod gradient_text_tests { + use serde_json::json; + + /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns + /// `x0..x1`. + fn ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 50.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + /// Red ink at the frame's left, blue at its right. + fn check(img: &designcraft_render::Rendered, what: &str) { + let (lr, lb) = ink(img, 0, 60); + let (rr, rb) = ink(img, 200, 300); + assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + + #[test] + fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + // A gradient swatch (an off-page object's unnamed one), red → blue. + let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) + .unwrap(); + let id = designcraft_doc::ItemId(shape.as_u64().unwrap()); + let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); + s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); + // The PDF, placed back and drawn: the same gradient. + let pdf = designcraft_pdf::export_pdf(&d, &s.cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "text.pdf", "x": 0, "y": 0, "width": 300})).unwrap(); + let d = p.doc().unwrap().doc.clone(); + check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); + } + + /// The columns the red → blue ink spans (black table edges left out). + fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { + let cols: Vec = (0..img.width) + .filter(|&x| { + (0..img.height).any(|y| { + let p = img.pixel(x, y); + p[1] < 100 && p[0].max(p[2]) > 100 + }) + }) + .collect(); + (*cols.first().unwrap(), *cols.last().unwrap()) + } + + /// Mean red and blue of the red → blue ink in columns `x0..x1`. + fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 && p[0].max(p[2]) > 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 20.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + #[test] + fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + let g = s + .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) + .unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + // A third of the frame's width: the cell's text, red → blue. + let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); + let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); + let text = &mut t.cell_mut(0, 0).unwrap().text; + text.insert(0, "HHHH"); + text.format_chars(0..4, |f| { + f.over.fill = Some(g.clone()); + f.over.size = Some(24.0); + }); + let mut rr = designcraft_render::Renderer::new(); + let cache = designcraft_compose::Cache::new(); + let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": crate::cmd::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})).unwrap(); + let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (x0, x1) = ink_span(&screen); + assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); + let q = (x1 - x0) / 4; + for (what, img) in [("screen", &screen), ("pdf", &placed)] { + let (lr, lb) = coloured_ink(img, x0, x0 + q); + let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); + assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + } +} + +#[cfg(test)] +mod kern_pair_tests { + use designcraft_doc::{Kerning, StoryId}; + use serde_json::json; + + use crate::Session; + + fn avatar() -> (Session, StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AVATAR"})).unwrap(); + (s, StoryId(r["story"].as_u64().unwrap())) + } + + fn kern_at(s: &Session, story: StoryId, pos: usize) -> Option { + s.doc().unwrap().doc.story(story).unwrap().format_after(pos).over.kerning + } + + #[test] + fn kerning_at_a_caret_kerns_the_pair_in_one_undo_step() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::Manual(-80.0)), "A, before the caret, spaces the A|V pair"); + assert_eq!(kern_at(&s, sid, 1), None); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let shown = s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["kerning"].clone(); + assert_eq!(shown, json!({"kind": "manual", "value": -80.0}), "the caret shows the pair's kerning"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), None); + } + + #[test] + fn kerning_a_selection_or_at_the_story_start_works_like_type_char() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 4})).unwrap(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": 20}})).unwrap(); + let kerned: Vec<_> = (0..6).map(|i| kern_at(&s, sid, i)).collect(); + let m = Some(Kerning::Manual(20.0)); + assert_eq!(kerned, [None, None, m, m, None, None]); + // No character before the caret: the value is for the next typing. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "none"}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(kern_at(&s, sid, 0), None); + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); + } + + #[test] + fn kerning_at_the_end_of_a_paragraph_or_story_leaves_the_text_alone() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AV\nTA"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + for caret in [2, 5] { + s.execute("text.select", &json!({"story": sid.0, "anchor": caret, "focus": caret})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no pair at {caret}: nothing is edited"); + assert_eq!((kern_at(&s, sid, 1), kern_at(&s, sid, 4)), (None, None), "caret {caret}"); + } + } +} + +#[cfg(test)] +mod pick_up_format_tests { + use designcraft_doc::ItemId; + use serde_json::json; + + use crate::Session; + + /// Typefool's video (12:04): select body text, Eyedropper, click the third header: the body + /// text takes the header's font, size and paragraph settings. DesignCraft copied the frame's + /// fill and stroke instead. + #[test] + fn the_eyedropper_copies_text_formatting_to_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let head = s.execute("frame.create", &json!({"rect": [36, 36, 400, 100], "content": "text", "text": "Innovative Design"})).unwrap(); + s.execute("text.select", &json!({"story": head["story"], "anchor": 0, "focus": 17})).unwrap(); + s.execute("type.char", &json!({"attrs": {"fontStyle": "Bold", "size": 18, "tracking": 20}})).unwrap(); + s.execute("type.para", &json!({"attrs": {"align": "center", "spaceAfter": 6}})).unwrap(); + let body = s.execute("frame.create", &json!({"rect": [36, 200, 400, 300], "content": "text", "text": "Body copy here.\nSecond."})).unwrap(); + s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); + // Click the header's middle (the frame's text starts at its top left). + let r = s.execute("type.pickUpFormat", &json!({"frame": head["id"], "point": [100, 50]})).unwrap(); + assert!(r.is_object(), "{r}"); + s.execute("text.select", &json!({"story": body["story"], "anchor": 0, "focus": 4})).unwrap(); + let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!((&a["chars"]["fontStyle"], &a["chars"]["size"], &a["chars"]["tracking"]), (&json!("Bold"), &json!(18.0), &json!(20.0)), "{a}"); + assert_eq!((&a["para"]["align"], &a["para"]["spaceAfter"]), (&json!("center"), &json!(6.0))); + // Only the selected characters change; the second paragraph keeps its own. + s.execute("text.select", &json!({"story": body["story"], "anchor": 6, "focus": 10})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], json!(12.0)); + s.execute("text.select", &json!({"story": body["story"], "anchor": 17, "focus": 20})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], json!("left")); + // Not a text frame, or nothing selected: refused. + let rect = s.execute("frame.create", &json!({"rect": [500, 500, 520, 520]})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("type.pickUpFormat", &json!({"frame": rect, "point": [510, 510]})).is_err()); + let _ = ItemId(rect); + } +} + +#[cfg(test)] +mod size_bounds_tests { + use serde_json::json; + + use crate::Session; + + /// Type size was not checked anywhere: `{"size": 0}` set type with no size at all, a negative + /// size was accepted the same way, and 1e9 pt asked the composer for a line a million times + /// the height of the page. The range is the one `type.ruby` already applies, 0.1 to 1296 pt. + #[test] + fn a_type_size_outside_the_range_is_refused() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [20, 20, 400, 300], "content": "text", "text": "Hello world"})).unwrap(); + let select = |s: &mut Session| { + s.execute("text.select", &json!({"story": f["story"].clone(), "anchor": 0, "focus": 5})).unwrap(); + }; + for size in [0.0, -12.0, 0.05, 1297.0, 1e9] { + select(&mut s); + let e = s.execute("type.char", &json!({"size": size})).expect_err("refused").to_string(); + assert!(e.contains("0.1 to 1296 pt"), "{size}: {e}"); + } + // The sizes in range still apply, including the ends. + for size in [0.1, 12.0, 1296.0] { + select(&mut s); + s.execute("type.char", &json!({"size": size})).unwrap_or_else(|e| panic!("{size}: {e}")); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"].as_f64(), Some(size)); + } + // And the styles that carry character attributes are checked the same way. + for (command, params) in [ + ("style.character.create", json!({"name": "Tiny", "chars": {"size": 0}})), + ("style.paragraph.create", json!({"name": "Huge", "chars": {"size": 2000}})), + ] { + let e = s.execute(command, ¶ms).expect_err("refused").to_string(); + assert!(e.contains("0.1 to 1296 pt"), "{command}: {e}"); + } + } +} diff --git a/crates/engine/src/cmd/text/opentype.rs b/crates/engine/src/cmd/text/opentype.rs new file mode 100644 index 000000000..f918c1cfe --- /dev/null +++ b/crates/engine/src/cmd/text/opentype.rs @@ -0,0 +1,425 @@ +use super::format::{Target, format_chars, format_targets}; +use super::story::selection_attrs; +#[cfg(test)] +use super::story::{SELECTED_TEXT_CHARS, selected_text_preview}; +use super::{Result, Session, Value, bad, json, ok}; + +pub(super) fn open_type(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::otf; + let cur = selection_attrs(s, &json!({}))?; + let mut list: Vec = + cur["chars"]["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + if let Some(tag) = p.get("feature").and_then(Value::as_str) { + if tag.is_empty() || tag.len() > 4 || !tag.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(bad("type.openType", format!("bad feature tag `{tag}`"))); + } + let on = p.get("on").and_then(Value::as_bool).unwrap_or(!otf::is_on(&list, tag)); + otf::set(&mut list, tag, on); + } + if let Some(f) = p.get("figures").and_then(Value::as_str) + && !otf::set_figures(&mut list, f) + { + return Err(bad("type.openType", format!("unknown figure style `{f}`"))); + } + if let Some(sets) = p.get("stylisticSets").and_then(Value::as_array) { + let mask = sets.iter().filter_map(Value::as_u64).filter(|n| (1..=20).contains(n)).fold(0u32, |m, n| m | 1 << (n - 1)); + otf::set_stylistic_sets(&mut list, mask); + } + format_chars(s, &json!({"otfFeatures": list}))?; + Ok(json!({"features": list, "figures": otf::figures(&list)})) +} + +/// One stretch of the selection: its length in bytes, the font it is set in and its features. +struct FeatureSegment { + len: usize, + font: (String, String), + applied: Vec, + ligatures: bool, +} + +/// The OpenType features of the fonts in the selection, with how much of the selection each one is +/// applied to and how much of it the fonts offer. A caret counts as one byte. `state` is `none`, +/// `some` or `all`. +pub(super) fn open_type_features(s: &mut Session, _p: &Value) -> Result { + let st = s.doc()?; + let mut segments: Vec = Vec::new(); + for Target { story: sid, cell, range: r } in format_targets(s) { + let Some(story) = st.doc.text_story(sid, cell) else { continue }; + let covered: Vec<(usize, usize, designcraft_doc::story::CharFormat)> = if r.is_empty() { + vec![(r.start, 1, story.format_after(r.start).clone())] + } else { + story + .runs() + .filter(|(run, _)| run.start < r.end && run.end > r.start) + .map(|(run, f)| (run.start, run.end.min(r.end).saturating_sub(run.start.max(r.start)), f.clone())) + .collect() + }; + for (pos, len, cf) in covered { + let Some(para) = story.paras.get(story.para_at(pos)) else { continue }; + let (_, base) = st.doc.styles.resolve_para(para); + let cp = st.doc.styles.resolve_char(&base, &cf); + segments.push(FeatureSegment { len, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); + } + } + if segments.is_empty() { + let (_, base) = st.doc.styles.resolve_para_style(designcraft_doc::story::BASIC_PARAGRAPH); + let cp = st.doc.styles.resolve_char(&base, &designcraft_doc::story::CharFormat::default()); + segments.push(FeatureSegment { len: 1, font: (cp.font_family, cp.font_style), applied: cp.otf_features, ligatures: cp.ligatures }); + } + let db = designcraft_fonts::FontDb::global(); + let mut fonts: std::collections::BTreeMap<(String, String), designcraft_fonts::FontFeatures> = std::collections::BTreeMap::new(); + for seg in &segments { + fonts.entry(seg.font.clone()).or_insert_with(|| designcraft_fonts::font_features(&db.face(&seg.font.0, &seg.font.1))); + } + let total: usize = segments.iter().map(|seg| seg.len).sum::().max(1); + let state = |covered: usize| match covered { + 0 => "none", + n if n >= total => "all", + _ => "some", + }; + // Bytes the feature is applied to, and bytes whose font offers it. + let measure = |tag: &str| { + let (mut applied, mut available) = (0, 0); + for seg in &segments { + // Standard ligatures are the `ligatures` attribute, which shaping reads, unless the + // feature list turns `liga` off. + let on = designcraft_doc::otf::is_on(&seg.applied, tag) && (tag != "liga" || seg.ligatures); + if on { + applied += seg.len; + } + if fonts.get(&seg.font).is_some_and(|f| f.tags.contains(tag)) { + available += seg.len; + } + } + (state(applied), state(available)) + }; + let tags: std::collections::BTreeSet = fonts.values().flat_map(|f| f.tags.iter().cloned()).collect(); + let mut features = Vec::new(); + for tag in tags.iter().filter(|t| !(t.len() == 4 && t.starts_with("ss") && t[2..].parse::().is_ok())) { + let (applied, available) = measure(tag); + features.push(json!({"tag": tag, "applied": applied, "available": available})); + } + // A stylistic set's name is shared when every font that offers it gives it the same name. + let mut sets = Vec::new(); + for set in 1..=20u32 { + let tag = format!("ss{set:02}"); + if !tags.contains(&tag) { + continue; + } + let names: std::collections::BTreeSet<&String> = fonts.values().filter_map(|f| f.stylistic_sets.get(&set)).collect(); + let name = if names.len() == 1 { names.first().map(ToString::to_string) } else { None }; + let (applied, available) = measure(&tag); + sets.push(json!({"set": set, "tag": tag, "name": name, "applied": applied, "available": available})); + } + let used: Vec = fonts.keys().map(|(family, style)| json!({"family": family, "style": style})).collect(); + Ok(json!({"fonts": used, "features": features, "stylisticSets": sets})) +} + +/// Turns OpenType features on or off across the selected text: `features` maps tags to whether they +/// are on. Each run keeps its other features, whichever font it is set in, and the change is one +/// undo step. +pub(super) fn set_features(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.feature"; + let changes: Vec<(String, bool)> = p + .get("features") + .and_then(Value::as_object) + .ok_or_else(|| bad(ID, "missing features: a map of tags to true or false"))? + .iter() + .map(|(tag, on)| Ok((tag.clone(), on.as_bool().ok_or_else(|| bad(ID, format!("`{tag}` needs true or false")))?))) + .collect::>()?; + let targets: Vec = format_targets(s).into_iter().filter(|t| !t.range.is_empty()).collect(); + if targets.is_empty() { + return Err(bad(ID, "select text")); + } + let styles = s.doc()?.doc.styles.clone(); + s.edit(|d, _| { + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + let runs: Vec<(std::ops::Range, designcraft_doc::story::CharFormat)> = + st.runs().filter(|(run, _)| run.start < t.range.end && run.end > t.range.start).map(|(run, f)| (run.clone(), f.clone())).collect(); + for (run, format) in runs { + let Some(para) = st.paras.get(st.para_at(run.start)).cloned() else { continue }; + let (_, base) = styles.resolve_para(¶); + let mut list = styles.resolve_char(&base, &format).otf_features; + for (tag, on) in &changes { + designcraft_doc::otf::set(&mut list, tag, *on); + } + let range = run.start.max(t.range.start)..run.end.min(t.range.end); + st.format_chars(range, |f| f.over.otf_features = Some(list.clone())); + } + } + ok() + }) +} + +#[cfg(test)] +mod open_type_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn text_selection_stays_on_utf8_boundaries() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); + let sid = r["story"].clone(); + s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 4})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 2)); + s.execute("text.select", &json!({"story": sid, "anchor": 4})).unwrap(); + s.execute("text.delete", &json!({"forward": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(t.story).unwrap().text, "é🙂x"); + s.execute("text.select", &json!({"story": sid, "anchor": 2})).unwrap(); + s.execute("story.setText", &json!({"story": sid, "text": "漢字"})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 0)); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 3); + } + + #[test] + fn replacing_a_range_stays_on_utf8_boundaries() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap(); + let sid = r["story"].clone(); + let story = designcraft_doc::StoryId(sid.as_u64().unwrap()); + // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing. + for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] { + let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"})); + assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x"); + } + s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x"); + // A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone. + s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap(); + s.execute("story.replaceRange", &json!({"story": sid, "start": 0, "end": 2, "text": ""})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.unwrap(); + let text = &st.doc.story(t.story).unwrap().text; + assert_eq!(text, "字🙂x"); + assert!(text.is_char_boundary(t.anchor) && text.is_char_boundary(t.focus), "caret {t:?} in {text:?}"); + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + } + + #[test] + fn selected_text_preview_stops_at_its_character_budget() { + let read = std::cell::Cell::new(0usize); + let prefix = ['\t', '\u{2003}', designcraft_doc::OBJECT_MARK]; + let chars = prefix.into_iter().chain(std::iter::repeat_n('字', 100_000)).inspect(|_| read.set(read.get() + 1)); + let preview = super::selected_text_preview(chars); + assert_eq!(preview, "字".repeat(super::SELECTED_TEXT_CHARS)); + assert_eq!(read.get(), prefix.len() + super::SELECTED_TEXT_CHARS, "the rest of a large selection is never copied or scanned"); + assert_eq!(super::selected_text_preview("A\nB".chars()), "A B"); + } + + #[test] + fn selection_attrs_report_the_selected_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute( + "frame.create", + &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Spring issue +What's new in typography this season, and why it matters to every designer"}), + ) + .unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 13})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "Spring issue", "on one line, trimmed"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 13, "focus": 200})).unwrap(); + let long = s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"].as_str().unwrap().to_string(); + assert!(long.starts_with("What's new") && (55..=60).contains(&long.chars().count()), "the line break skipped, capped: {long}"); + s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["selectedText"], "", "a caret selects nothing"); + } + + #[test] + fn open_type_toggles_reach_the_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Office 1/2 0"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 12})).unwrap(); + let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); + assert_eq!(r["features"], json!(["dlig"])); + let r = s.execute("type.openType", &json!({"feature": "dlig"})).unwrap(); + assert_eq!(r["features"], json!([]), "toggles off"); + let r = s.execute("type.openType", &json!({"figures": "proportionalOldstyle", "stylisticSets": [1, 2]})).unwrap(); + assert_eq!(r["figures"], "proportionalOldstyle"); + let a = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let f: Vec<&str> = a["chars"]["otfFeatures"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); + assert!(f.contains(&"onum") && f.contains(&"ss02"), "{f:?}"); + assert!(s.execute("type.openType", &json!({"feature": "bad tag!"})).is_err()); + } + + #[test] + fn break_link_to_paragraph_and_character_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Head", "para": {"spaceBefore": 6}, "chars": {"size": 24}})).unwrap(); + s.execute("style.character.create", &json!({"name": "Em", "chars": {"fontStyle": "Italic"}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Title here"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("style.character.apply", &json!({"name": "Em"})).unwrap(); + let before = s.execute("type.selectionAttrs", &json!({})).unwrap(); + s.execute("style.breakLink", &json!({"kind": "character"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 10})).unwrap(); + s.execute("style.breakLink", &json!({"kind": "paragraph"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + let after = s.execute("type.selectionAttrs", &json!({})).unwrap(); + assert_eq!(after["paragraphStyle"], "[No Paragraph Style]"); + assert_eq!(after["characterStyle"], "[None]"); + assert_eq!(after["chars"], before["chars"], "same look"); + assert_eq!(after["para"], before["para"]); + } + + /// Old-style figures shape to other glyphs than the default lining ones (Source Serif 4). + #[test] + fn oldstyle_figures_change_the_glyphs() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "2024"})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let gids = |s: &Session| { + let d = &s.doc().unwrap().doc; + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames[0].lines[0].glyphs.iter().map(|g| g.gid).collect::>() + }; + let lining = gids(&s); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.openType", &json!({"figures": "proportionalOldstyle"})).unwrap(); + assert_ne!(gids(&s), lining); + } + + /// A blank line (an empty paragraph) has no characters to select, but its hidden paragraph + /// mark can still take a leading change right away: otherwise there'd be no way to adjust the + /// line height of a blank line at all. + #[test] + fn leading_applies_to_an_empty_lines_hidden_paragraph_mark() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + // The blank line between the two paragraphs: a collapsed caret, since it has no text. + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"leading": {"kind": "points", "value": 40.0}}})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + // `char_format_at` resolves a boundary to the *preceding* run (new typing's format), so + // check just past the mark's own one-byte run, which the mark alone occupies. + assert_eq!(st.char_format_at(blank + 1).over.leading, Some(designcraft_doc::Leading::Points(40.0))); + let d = s.doc().unwrap().doc.clone(); + let cs = designcraft_compose::compose_story(&d, sid, &Default::default()); + let blank_line = cs.frames[0].lines.iter().find(|l| l.range == (blank..blank)).unwrap(); + assert_eq!(blank_line.leading, 40.0, "the blank line's own height changed"); + } + + /// Formatting chosen on a blank line is also what gets typed there, not the format of the + /// previous paragraph's mark that new text would otherwise take. + #[test] + fn text_typed_on_a_blank_line_takes_the_format_set_there() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + assert_eq!(st.text, "Para one\nMiddle\nPara two"); + for b in blank..blank + "Middle\n".len() { + assert_eq!(st.format_after(b).over.size, Some(30.0), "byte {b}"); + } + assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); + } + + /// A blank line keeps its format for typing after the caret has left it: text typed on it + /// takes its paragraph mark's format, with no pending typing format. + #[test] + fn a_blank_lines_mark_format_is_typed_after_the_caret_returns() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = "Para one\n\nPara two"; + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let blank = text.find("\n\n").unwrap() + 1; + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": blank})).unwrap(); + assert!(s.typing_format.is_none()); + s.execute("text.insert", &json!({"text": "Middle"})).unwrap(); + let st = s.doc().unwrap().doc.story(sid).unwrap(); + assert_eq!(st.text, "Para one\nMiddle\nPara two"); + assert_eq!(st.format_after(blank).over.size, Some(30.0)); + assert_eq!(st.format_after(blank - 1).over.size, None, "the first paragraph's mark keeps its size"); + assert_eq!(st.format_after(2).over.size, None); + } +} + +#[cfg(test)] +mod open_type_features_tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn feature<'a>(v: &'a Value, tag: &str) -> &'a Value { + v["features"].as_array().unwrap().iter().find(|f| f["tag"] == tag).unwrap_or(&Value::Null) + } + + #[test] + fn the_selected_fonts_features_are_listed_with_their_state() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello fi"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 8})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(v["fonts"][0]["family"], "Source Serif 4", "{v}"); + assert_eq!(feature(&v, "liga")["applied"], "all", "liga is on by default: {v}"); + assert_eq!(feature(&v, "kern")["available"], "all", "{v}"); + } + + #[test] + fn a_feature_on_part_of_the_selection_is_some_and_on_all_of_it_is_all() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Hello world"})).unwrap(); + let story = r["story"].clone(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + s.execute("type.feature", &json!({"features": {"lnum": true}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "some", "{v}"); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 5})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"lnum": false}})).unwrap(); + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 11})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "lnum")["applied"], "none", "{v}"); + } + + #[test] + fn standard_ligatures_follow_the_ligatures_attribute() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "office"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 6})).unwrap(); + s.execute("type.char", &json!({"attrs": {"ligatures": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "Character › Ligatures off: {v}"); + s.execute("type.char", &json!({"attrs": {"ligatures": true}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "all", "{v}"); + s.execute("type.feature", &json!({"features": {"liga": false}})).unwrap(); + let v = s.execute("type.openTypeFeatures", &json!({})).unwrap(); + assert_eq!(feature(&v, "liga")["applied"], "none", "-liga in the feature list: {v}"); + } +} diff --git a/crates/engine/src/cmd/text/story.rs b/crates/engine/src/cmd/text/story.rs new file mode 100644 index 000000000..e3547f88d --- /dev/null +++ b/crates/engine/src/cmd/text/story.rs @@ -0,0 +1,455 @@ +use super::caret::story_of; +use super::format::{Target, effective_char_format, format_targets}; +use super::{ItemId, Result, Session, StoryId, Value, bad, bool_or, json}; + +/// The stories of the text selection, the selected cells and the selected text frames. +fn selected_stories(s: &Session) -> Result> { + let st = s.doc()?; + let mut stories: Vec = st.selection.text.map(|t| t.story).into_iter().chain(st.selection.cells.map(|c| c.story)).collect(); + stories.dedup(); + for id in &st.selection.items { + if let Some(tf) = st.doc.item(*id).and_then(|i| i.text_frame()) + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + } + Ok(stories) +} + +/// Type ▸ Story: the story-wide options (Optical Margin Alignment and its size). +pub(super) fn story_options(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "story.options"; + let on = match p.get("opticalMargin") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(CMD, "`opticalMargin` must be a bool"))?), + }; + let size = match p.get("opticalMarginSize") { + None => None, + Some(v) => Some( + v.as_f64() + .and_then(designcraft_doc::OpticalMargin::clamp_size) + .ok_or_else(|| bad(CMD, "`opticalMarginSize` must be a finite number of points"))?, + ), + }; + if on.is_none() && size.is_none() { + return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize")); + } + let explicit = |key| p.get(key).filter(|v| !v.is_null()); + let stories = if explicit("story").is_some() || explicit("frame").is_some() { + // Explicit targets must not silently fall back to the current selection. + let doc = &s.doc()?.doc; + for key in ["story", "frame"] { + if let Some(value) = explicit(key) { + let id = value.as_u64().ok_or_else(|| bad(CMD, format!("`{key}` must be an unsigned integer")))?; + let sid = if key == "story" { + StoryId(id) + } else { + let item = doc.item(ItemId(id)).ok_or_else(|| bad(CMD, format!("no such frame {id}")))?; + item.text_frame().ok_or_else(|| bad(CMD, format!("frame {id} holds no text")))?.story + }; + if doc.story(sid).is_none() { + return Err(bad(CMD, format!("no such story {}", sid.0))); + } + } + } + vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?] + } else { + selected_stories(s)? + }; + if stories.is_empty() { + return Err(bad(CMD, "no story: select text or a text frame, or give story or frame")); + } + s.edit(|d, _| { + for sid in &stories { + let st = d.story_mut(*sid).ok_or(designcraft_doc::DocError::NoStory(*sid))?; + let mut om = st.optical_margin; + om.on = on.unwrap_or(om.on); + om.size = size.unwrap_or(om.size); + if om != st.optical_margin { + st.optical_margin = om; + st.rev = st.rev.wrapping_add(1); + } + } + Ok(json!({"stories": stories.len()})) + }) +} + +/// Type ▸ Story Direction. +pub(super) fn story_direction(s: &mut Session, p: &Value) -> Result { + let vertical = p.get("vertical").and_then(Value::as_bool).ok_or_else(|| bad("type.storyDirection", "missing vertical"))?; + let stories = selected_stories(s)?; + s.edit(|d, _| { + set_story_direction(d, &stories, vertical); + Ok(json!({"stories": stories.len()})) + }) +} + +/// Set the direction of `stories` (all their frames turn together). +pub(crate) fn set_story_direction(d: &mut designcraft_doc::Document, stories: &[StoryId], vertical: bool) { + for sid in stories { + if let Some(st) = d.story_mut(*sid) + && st.vertical != vertical + { + st.vertical = vertical; + st.rev = st.rev.wrapping_add(1); + } + } +} + +/// How much of the selected text `type.selectionAttrs` reports. +pub(super) const SELECTED_TEXT_CHARS: usize = 60; + +pub(super) fn selected_text_preview(chars: impl Iterator) -> String { + chars + .map(|c| if c.is_control() || c == designcraft_doc::OBJECT_MARK { ' ' } else { c }) + .skip_while(|c| c.is_whitespace()) + .take(SELECTED_TEXT_CHARS) + .collect() +} + +pub(super) fn selection_attrs(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let targets = format_targets(s); + let Some(Target { story: sid, cell, range: r }) = targets.first().cloned() else { return Ok(Value::Null) }; + let story = st.doc.text_story(sid, cell).ok_or(designcraft_doc::DocError::NoStory(sid))?; + let pi = story.para_at(r.start); + let pf = &story.paras[pi]; + let (pp, base) = st.doc.styles.resolve_para(pf); + let cf = effective_char_format(s, &Target { story: sid, cell, range: r.clone() })?; + let cp = st.doc.styles.resolve_char(&base, &cf); + let (style_para, style_chars) = st.doc.styles.resolve_para_style(&pf.style); + let mut character_base = base.clone(); + let character_style = st.doc.styles.char_style_attrs(&cf.style); + character_base.apply(&character_style); + let char_delta = character_base.delta(&cp); + let style_keys = character_style.keys(); + let character_style_overrides = char_delta.keys().iter().filter(|key| style_keys.contains(key)).count(); + // The selected text (its start, on one line), for previews such as the font menu's. + let selected = selected_text_preview(story.text.get(r.start..r.end).unwrap_or_default().chars()); + let mut out = json!({"story": sid.0, "paragraphStyle": pf.style, "characterStyle": cf.style, "para": pp, "chars": cp, + "paraOverrides": style_para.delta(&pp).count() + style_chars.delta(&base).count(), + "charOverrides": char_delta.count(), "characterStyleOverrides": character_style_overrides, + "selectedText": selected.trim()}); + let mut stories = selected_stories(s)?; + if stories.is_empty() { + stories.push(sid); + } + out["storyOptions"] = story_options_of(&st.doc, &stories); + if bool_or(p, "mixed", false) { + out["paraMixed"] = json!(mixed_para_attrs(&st.doc, &targets)); + } + Ok(out) +} + +/// The story options of `stories`, as story.options names them; a value that differs between +/// the stories is null. +fn story_options_of(doc: &designcraft_doc::Document, stories: &[StoryId]) -> Value { + let mut values = stories.iter().filter_map(|sid| doc.story(*sid)).map(|st| st.optical_margin); + let Some(first) = values.next() else { return json!({"opticalMargin": null, "opticalMarginSize": null}) }; + let (mut on, mut size) = (Some(first.on), Some(first.size)); + for om in values { + on = on.filter(|v| *v == om.on); + size = size.filter(|v| *v == om.size); + } + json!({"opticalMargin": on, "opticalMarginSize": size}) +} + +/// The paragraph attributes whose resolved values differ across the paragraphs of `targets`. +fn mixed_para_attrs(doc: &designcraft_doc::Document, targets: &[Target]) -> Vec { + let mut first: Option> = None; + let mut mixed = std::collections::BTreeSet::new(); + for t in targets { + let Some(story) = doc.text_story(t.story, t.cell) else { continue }; + let a = story.para_at(t.range.start); + let b = story.para_at(t.range.end.max(t.range.start)); + for pf in story.paras.get(a..=b).unwrap_or(&[]) { + let Ok(Value::Object(v)) = serde_json::to_value(doc.styles.resolve_para(pf).0) else { continue }; + match &first { + None => first = Some(v), + Some(f) => mixed.extend(v.into_iter().filter(|(k, x)| f.get(k) != Some(x)).map(|(k, _)| k)), + } + } + } + mixed.into_iter().collect() +} + +#[cfg(test)] +mod story_options_tests { + use super::*; + + #[test] + fn malformed_explicit_story_targets_do_not_change_the_selection() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Selected"})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + for target in ["story", "frame"] { + for invalid in [json!("invalid"), json!(-1), json!(1.5), json!(true), json!({}), json!([]), json!(999_999)] { + let mut params = json!({"opticalMargin": true}); + params[target] = invalid; + assert!(s.execute("story.options", ¶ms).is_err(), "{params}"); + assert!(!story(&s, sid).on, "{params} changed the selected story"); + } + } + // Selection fallback still works when neither explicit target is supplied. + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert!(story(&s, sid).on); + s.execute("story.options", &json!({"frame": r["id"], "opticalMargin": false})).unwrap(); + assert!(!story(&s, sid).on); + } + + #[test] + fn story_options_treat_null_targets_as_absent_and_validate_both_explicit_ids() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Explicit"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Selected"})).unwrap(); + let aid = StoryId(a["story"].as_u64().unwrap()); + let bid = StoryId(b["story"].as_u64().unwrap()); + for targets in [json!({"story": null}), json!({"frame": null}), json!({"story": null, "frame": null})] { + let mut p = targets; + p["opticalMargin"] = json!(true); + s.execute("story.options", &p).unwrap(); + assert!(story(&s, bid).on); + assert!(!story(&s, aid).on); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!story(&s, bid).on); + } + for targets in [json!({"story": null, "frame": a["id"]}), json!({"story": a["story"], "frame": null})] { + let mut p = targets; + p["opticalMargin"] = json!(true); + s.execute("story.options", &p).unwrap(); + assert!(story(&s, aid).on); + assert!(!story(&s, bid).on); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Even a frame ignored by story precedence must be valid when explicitly supplied. + let non_text = s.execute("frame.create", &json!({"rect": [400, 0, 500, 100]})).unwrap(); + for (targets, message) in [ + (json!({"frame": 999_999}), "no such frame 999999".to_string()), + (json!({"frame": non_text["id"]}), format!("frame {} holds no text", non_text["id"])), + (json!({"story": 999_999}), "no such story 999999".to_string()), + (json!({"story": a["story"], "frame": 999_999}), "no such frame 999999".to_string()), + ] { + let mut p = targets; + p["opticalMargin"] = json!(true); + assert!(s.execute("story.options", &p).unwrap_err().to_string().contains(&message), "{p}: expected {message}"); + assert!(!story(&s, aid).on); + assert!(!story(&s, bid).on); + } + } + + fn first_x(s: &Session, sid: StoryId) -> f64 { + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + cs.frames[0].lines[0].glyphs[0].x + } + + fn story(s: &Session, sid: StoryId) -> designcraft_doc::OpticalMargin { + s.doc().unwrap().doc.story(sid).unwrap().optical_margin + } + + #[test] + fn story_options_set_optical_margin_with_one_undo_step_each() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + // The selected frame's story. + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + let hung = first_x(&s, sid); + assert!(hung < flush - 1.0, "{hung} {flush}"); + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 24})).unwrap(); + assert_eq!(story(&s, sid).size, 24.0); + assert!(first_x(&s, sid) < hung - 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s, sid), designcraft_doc::OpticalMargin { on: true, size: 12.0 }); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!story(&s, sid).on); + assert_eq!(first_x(&s, sid), flush); + // Hostile sizes. + s.execute("story.options", &json!({"story": sid.0, "opticalMarginSize": 1e9})).unwrap(); + assert_eq!(story(&s, sid).size, designcraft_doc::OpticalMargin::MAX_SIZE); + for bad in [json!({"opticalMarginSize": "big"}), json!({"opticalMargin": 1}), json!({}), json!({"story": 999_999, "opticalMargin": true})] { + assert!(s.execute("story.options", &bad).is_err(), "{bad}"); + } + } + + #[test] + fn ignore_optical_margin_through_type_para_and_paragraph_styles() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "“Hung” words."})).unwrap(); + let sid = StoryId(r["story"].as_u64().unwrap()); + let flush = first_x(&s, sid); + s.execute("story.options", &json!({"opticalMargin": true})).unwrap(); + assert!(first_x(&s, sid) < flush - 1.0); + let ignored = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().paras[0].para.ignore_optical_margin; + s.execute("type.para", &json!({"attrs": {"ignoreOpticalMargin": true}})).unwrap(); + assert_eq!(ignored(&s), Some(true)); + assert_eq!(first_x(&s, sid), flush); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ignored(&s), None); + s.execute("style.paragraph.create", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": true}})).unwrap(); + let resolved = |s: &Session| s.doc().unwrap().doc.styles.resolve_para_style("Flush").0.ignore_optical_margin; + assert!(resolved(&s)); + s.execute("style.paragraph.edit", &json!({"name": "Flush", "para": {"ignoreOpticalMargin": false}})).unwrap(); + assert!(!resolved(&s)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(resolved(&s)); + s.execute("style.paragraph.apply", &json!({"name": "Flush"})).unwrap(); + assert_eq!(first_x(&s, sid), flush); + } + + #[test] + fn selection_attrs_report_the_story_options_with_differences_as_null() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "One"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Two"})).unwrap(); + let options = |s: &mut Session| s.execute("type.selectionAttrs", &json!({})).unwrap()["storyOptions"].clone(); + assert_eq!(options(&mut s), json!({"opticalMargin": false, "opticalMarginSize": 12.0})); + s.execute("story.options", &json!({"story": b["story"], "opticalMargin": true, "opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": true, "opticalMarginSize": 18.0}), "the selected frame's story"); + s.execute("selection.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": null})); + s.execute("story.options", &json!({"opticalMarginSize": 18})).unwrap(); + assert_eq!(options(&mut s), json!({"opticalMargin": null, "opticalMarginSize": 18.0})); + } +} + +#[cfg(test)] +mod story_direction_tests { + use designcraft_doc::{ItemId, StoryId}; + use serde_json::json; + + use crate::Session; + + fn frame(s: &mut Session, rect: [f64; 4], text: &str, vertical: bool) -> (ItemId, StoryId) { + let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false, "vertical": vertical})).unwrap(); + (ItemId(r["id"].as_u64().unwrap()), StoryId(r["story"].as_u64().unwrap())) + } + + /// Which frames of the story are composed vertically, in thread order. + fn composed(s: &Session, sid: StoryId) -> Vec { + s.cache.get(&s.doc().unwrap().doc, sid, None).frames.iter().map(|f| f.vertical).collect() + } + + #[test] + fn story_direction_turns_every_frame_of_the_story() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", false); + let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); + s.edit(|d, _| Ok(d.thread(a, b)?)).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + // From the second frame selected, through Type ▸ Story Direction. + s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("type.storyDirection", &json!({"vertical": true})).unwrap(); + assert_eq!(composed(&s, sid), [true, true]); + // From a caret in the text. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.storyDirection", &json!({"vertical": false})).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + // The frame option sets the frame's story. + s.execute("object.textFrameOptions", &json!({"ids": [a.0], "vertical": true})).unwrap(); + assert_eq!(composed(&s, sid), [true, true]); + // Undo puts it back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(composed(&s, sid), [false, false]); + } + + #[test] + fn a_frame_threaded_onto_a_vertical_story_turns_vertical() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let (a, sid) = frame(&mut s, [72.0, 72.0, 200.0, 400.0], "縦書きの文章です。", true); + let (b, _) = frame(&mut s, [300.0, 72.0, 428.0, 400.0], "", false); + let c = s.execute("frame.create", &json!({"rect": [450, 72, 550, 400]})).unwrap(); + let c = ItemId(c["id"].as_u64().unwrap()); + s.edit(|d, _| { + d.thread(a, b)?; + Ok(d.thread(b, c)?) + }) + .unwrap(); + assert_eq!(composed(&s, sid), [true, true, true]); + } +} + +#[cfg(test)] +mod story_query_contract_tests { + use serde_json::json; + #[test] + fn missing_story_errors_but_valid_empty_and_selected_stories_succeed() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + assert!(s.execute("story.get", &json!({"story":999})).unwrap_err().to_string().contains("no such story")); + let f = s.execute("frame.create", &json!({"rect":[36,36,300,200],"content":"text"})).unwrap(); + assert_eq!(s.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], ""); + assert_eq!(s.execute("story.get", &json!({"frame":f["id"]})).unwrap()["text"], ""); + s.execute("text.select", &json!({"story":f["story"],"anchor":0})).unwrap(); + assert_eq!(s.execute("story.get", &json!({})).unwrap()["text"], ""); + } + + #[test] + fn kenten_shatai_auto_tcy_and_grid_settings_take_checked_values() { + use designcraft_doc::cjk::CharacterAlignment; + use designcraft_doc::cjk_settings::{KentenKind, KentenPosition}; + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 200, 400], "content": "text", "text": "縦書き12年", "vertical": true})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": "縦書き".len()})).unwrap(); + let chars = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].char_format_at(1).over.clone(); + let para = |s: &crate::Session| s.doc().unwrap().doc.stories[&sid].paras[0].para.clone(); + // Any setting turns kenten on; values are clamped and text is capped. + let long = "★".repeat(10_000); + s.execute( + "type.kenten", + &json!({"kind": "custom", "character": long, "size": 1e300, "distance": -1e300, "xScale": 0, "fillTint": 250, "position": "belowLeft"}), + ) + .unwrap(); + let a = chars(&s); + assert_eq!((a.kenten, a.kenten_kind, a.kenten_position), (Some(true), Some(KentenKind::Custom), Some(KentenPosition::BelowLeft))); + assert_eq!(a.kenten_character.as_ref().map(|c| c.chars().count()), Some(designcraft_doc::cjk_settings::KENTEN_MARK_MAX_CHARS)); + assert_eq!( + (a.kenten_size, a.kenten_distance, a.kenten_x_scale, a.kenten_fill_tint), + (Some(Some(1296.0)), Some(-1296.0), Some(0.01), Some(Some(1.0))) + ); + // Bad values change nothing. + for bad in [json!({"kind": "sparkle"}), json!({"size": "big"}), json!({"character": 5}), json!({"on": "yes"}), json!({"overprintFill": 1})] { + assert!(s.execute("type.kenten", &bad).is_err(), "{bad}"); + } + assert_eq!(chars(&s), a); + s.execute("type.kenten", &json!({"on": false})).unwrap(); + assert_eq!((chars(&s).kenten, chars(&s).kenten_kind), (Some(false), Some(KentenKind::Custom)), "off keeps the settings"); + // Shatai. + s.execute("type.shatai", &json!({"magnification": 1e9, "angle": -30, "adjustRotation": true})).unwrap(); + let a = chars(&s); + assert_eq!((a.shatai_magnification, a.shatai_angle, a.shatai_adjust_rotation), (Some(90.0), Some(150.0), Some(true))); + assert!(s.execute("type.shatai", &json!({})).is_err()); + assert!(s.execute("type.shatai", &json!({"angle": [45]})).is_err()); + // Paragraph settings. + s.execute("type.autoTateChuYoko", &json!({"digits": 1e12, "includeRoman": true})).unwrap(); + assert_eq!((para(&s).auto_tcy, para(&s).auto_tcy_include_roman), (Some(designcraft_compose::shape::AUTO_TCY_MAX), Some(true))); + s.execute("type.gridSettings", &json!({"alignment": "emCenter", "firstLineOnly": true, "gyoudori": 1e12, "paragraphGyoudori": true})) + .unwrap(); + let p = para(&s); + assert_eq!((p.grid_align, p.grid_reference), (Some(designcraft_doc::GridAlign::FirstLineOnly), Some(CharacterAlignment::EmCenter))); + assert_eq!((p.grid_gyoudori, p.paragraph_gyoudori), (Some(100), Some(true))); + assert!(s.execute("type.gridSettings", &json!({"alignment": "middle"})).is_err()); + s.execute("type.gridSettings", &json!({"alignment": "none"})).unwrap(); + assert_eq!(para(&s).grid_align, Some(designcraft_doc::GridAlign::None)); + s.execute("type.rotateRoman", &json!({})).unwrap(); + assert_eq!(para(&s).rotate_roman, Some(true)); + s.execute("type.rotateRoman", &json!({})).unwrap(); + assert_eq!(para(&s).rotate_roman, Some(false)); + s.execute("type.romanWordBreak", &json!({"on": true})).unwrap(); + assert_eq!(para(&s).roman_word_break, Some(true)); + // The story still composes with every setting at its limit. + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + assert!(!cs.frames.is_empty()); + } +} diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index d2e513430..682ccebde 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -73,9 +73,267 @@ pub fn specs() -> Vec { s.execute("text.insert", &json!({"text": c.to_string()})) } ), + cmd!( + "variables.convertToText", + "Convert Variable to Text", + ["Type", "Text Variables"], + None, + "{} — the variable instances in the selected text become plain text of the value each shows on its page; the variables stay defined", + has_selected_instance, + convert_selection + ), + cmd!( + "variables.convertAllToText", + "Convert to Text", + [], + None, + "{name} — every instance of the variable in the document becomes plain text of the value it shows on its page (Text Variables › Convert to Text); the variable stays defined", + has_doc, + convert_all + ), ] } +/// The selected text holds a variable instance (InDesign: select the variable, then Convert +/// Variable to Text). The endnote story is rebuilt from the stories' endnotes, so an edit there +/// would not last. +fn has_selected_instance(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + let st = s.active().ok_or("no document")?; + let t = st.selection.text.ok_or("no text selected")?; + let held = st.doc.endnote_story != Some(t.story) + && st + .doc + .text_story(t.story, t.cell) + .and_then(|x| x.text.get(t.range())) + .is_some_and(|x| x.chars().any(|c| designcraft_doc::vars::var_index(c).is_some())); + if held { Ok(()) } else { Err("no text variable selected".into()) } +} + +/// The page story byte `pos` is shown on, which a variable's value depends on: overset text takes +/// the page of the story's last frame; parent pages and the pasteboard have none. +fn shown_page(d: &designcraft_doc::Document, cs: &designcraft_compose::ComposedStory, pos: usize) -> Option { + designcraft_compose::vars::page_of(d, cs, pos).or_else(|| cs.frames.last().and_then(|f| d.page_of_item(f.frame))) +} + +/// Values of every variable on the pages asked for, as composition shows them (running headers +/// included), computed once per page. +struct PageValues<'a> { + doc: &'a designcraft_doc::Document, + running: std::sync::Arc, + pages: std::collections::HashMap, Vec>, +} + +impl PageValues<'_> { + fn get(&mut self, i: usize, page: Option) -> String { + let (doc, running) = (self.doc, &self.running); + let v = self.pages.entry(page).or_insert_with(|| designcraft_compose::vars::values(doc, page, Some(running))); + v.get(i).cloned().unwrap_or_default() + } +} + +/// Byte offsets of the variable instances in `range` of `text` (of variable `which`, or any), +/// with their variable index. +fn instances(text: &str, range: std::ops::Range, which: Option) -> Vec<(usize, usize)> { + let Some(t) = text.get(range.clone()) else { return Vec::new() }; + t.char_indices() + .filter_map(|(k, c)| designcraft_doc::vars::var_index(c).filter(|i| which.is_none_or(|w| w == *i)).map(|i| (range.start + k, i))) + .collect() +} + +/// Replaces the instances at `at` (byte, variable) in `st` with `value(byte)`, last first so the +/// earlier offsets hold. Returns the change in length before each replaced byte, in text order. +fn replace_instances(st: &mut designcraft_doc::Story, at: &[(usize, usize)], value: &dyn Fn(usize) -> String) -> Vec<(usize, isize)> { + let mut grown = Vec::new(); + for &(pos, i) in at.iter().rev() { + let len = var_char(i).map_or(0, char::len_utf8); + let v = value(pos); + st.replace(pos..pos + len, &v); + grown.push((pos, v.len() as isize - len as isize)); + } + grown.reverse(); + grown +} + +/// Moves byte `b` of a story by the growth of the instances replaced before it. +fn shifted(b: usize, grown: &[(usize, isize)]) -> usize { + let d: isize = grown.iter().filter(|(pos, _)| *pos < b).map(|(_, g)| g).sum(); + b.saturating_add_signed(d) +} + +/// Replaces every instance of variable `i` in `st`, its table cells and its notes with `value`. +/// Returns the growth of `st`'s own text, as [`replace_instances`]. +fn replace_nested(st: &mut designcraft_doc::Story, i: usize, value: &str, depth: usize) -> Vec<(usize, isize)> { + if depth > MAX_DEPTH { + return Vec::new(); + } + let at = instances(&st.text, 0..st.text.len(), Some(i)); + let grown = replace_instances(st, &at, &|_| value.to_string()); + let mut nested = false; + for t in st.tables.values_mut() { + if t.cells.iter().any(|c| holds(&c.text, i, depth + 1)) { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, value, depth + 1); + } + nested = true; + } + } + for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) { + if holds(¬e.text, i, depth + 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, value, depth + 1); + nested = true; + } + } + if nested { + st.rev = st.rev.wrapping_add(1); + } + grown +} + +/// Whether `st` or text nested in it holds an instance of variable `i`. +fn holds(st: &designcraft_doc::Story, i: usize, depth: usize) -> bool { + depth <= MAX_DEPTH + && (st.text.chars().any(|c| designcraft_doc::vars::var_index(c) == Some(i)) + || st.tables.values().any(|t| t.cells.iter().any(|c| holds(&c.text, i, depth + 1))) + || st.notes.iter().chain(st.endnotes.iter()).any(|n| holds(&n.text, i, depth + 1))) +} + +/// Byte offset in `st` of the anchor of table `id`. +fn table_anchor(st: &designcraft_doc::Story, id: u64) -> Option { + let (pi, _) = st.paras.iter().enumerate().find(|(_, p)| p.table == Some(id))?; + let r = st.para_ranges().into_iter().nth(pi)?; + Some(r.start + st.text.get(r)?.find(designcraft_doc::story::TABLE_ANCHOR)?) +} + +fn convert_selection(s: &mut Session, _: &Value) -> Result { + const ID: &str = "variables.convertToText"; + let t = s.doc()?.selection.text.ok_or_else(|| bad(ID, "no text selected"))?; + let range = t.range(); + let (at, values) = { + let d = &s.doc()?.doc; + let st = d.story(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let text = d.text_story(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + // Text in a cell or a footnote is on the page of its table's or note's anchor. + let anchor = match t.cell { + None => None, + Some(c) if c.table == designcraft_doc::FOOTNOTE_TABLE => st.note_anchor(c.row as u64), + Some(c) => table_anchor(st, c.table), + }; + let cs = s.cache.get(d, t.story, None); + let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() }; + let at = instances(&text.text, range.clone(), None); + let values: std::collections::HashMap = + at.iter().map(|&(pos, i)| (pos, pv.get(i, shown_page(d, &cs, anchor.unwrap_or(pos))))).collect(); + (at, values) + }; + if at.is_empty() { + return Err(bad(ID, "no text variable selected")); + } + s.edit(|d, sel| { + let st = d.text_story_mut(t.story, t.cell).ok_or(designcraft_doc::DocError::NoStory(t.story))?; + let grown = replace_instances(st, &at, &|pos| values.get(&pos).cloned().unwrap_or_default()); + // The converted text stays selected. + sel.text = Some(designcraft_doc::TextSel { anchor: range.start, focus: shifted(range.end, &grown), ..t }); + Ok(json!({"converted": at.len()})) + }) +} + +/// What converting one story's instances of a variable writes: the value for each instance in +/// its own text, and for the text of each table (by id), footnote and endnote, the value on the +/// page of its anchor. +struct StoryPlan { + sid: designcraft_doc::StoryId, + own: Vec<(usize, String)>, + tables: Vec<(u64, String)>, + notes: Vec, + endnotes: Vec, +} + +fn convert_all(s: &mut Session, p: &Value) -> Result { + const ID: &str = "variables.convertAllToText"; + let name = str_param(p, "name").ok_or_else(|| bad(ID, "missing name"))?; + let (i, plans) = { + let d = &s.doc()?.doc; + let i = d.variable(name).ok_or_else(|| bad(ID, format!("no variable `{name}`")))?; + let mut pv = PageValues { doc: d, running: s.cache.running_index(d), pages: Default::default() }; + let mut plans = Vec::new(); + for (sid, st) in &d.stories { + if Some(*sid) == d.endnote_story || !holds(st, i, 0) { + continue; + } + let cs = s.cache.get(d, *sid, None); + let mut value = |pos: usize| pv.get(i, shown_page(d, &cs, pos)); + let own = instances(&st.text, 0..st.text.len(), Some(i)).into_iter().map(|(pos, _)| (pos, value(pos))).collect(); + let tables = st.tables.keys().map(|id| (*id, table_anchor(st, *id).map(&mut value).unwrap_or_default())).collect(); + let notes = st.text.match_indices(designcraft_doc::FOOTNOTE_REF).map(|(k, _)| value(k)).collect(); + let endnotes = st.text.match_indices(designcraft_doc::ENDNOTE_REF).map(|(k, _)| value(k)).collect(); + plans.push(StoryPlan { sid: *sid, own, tables, notes, endnotes }); + } + (i, plans) + }; + s.edit(|d, sel| { + for plan in &plans { + convert_plan(d, sel, i, plan)?; + } + Ok(Value::Null) + }) +} + +/// Converts one story's instances of variable `i` as planned, keeping the selection in place. +fn convert_plan(d: &mut designcraft_doc::Document, sel: &mut designcraft_doc::Selection, i: usize, plan: &StoryPlan) -> Result<()> { + // A selection in a table cell or a footnote keeps its place in that text: it is + // converted first, so the loop below finds nothing left there. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid) + && let Some(c) = t.cell + { + let value = match c.footnote_id() { + Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)), + None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v), + }; + if let Some(v) = value + && let Some(text) = d.text_story_mut(plan.sid, Some(c)) + { + let grown = replace_nested(text, i, v, 1); + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + } + let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?; + let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect(); + let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default()); + if replace_in_tables_and_notes(st, i, plan) { + st.rev = st.rev.wrapping_add(1); + } + // A selection in the story keeps its place in the text. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) { + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + Ok(()) +} + +/// Replaces variable `i` in the tables and foot/endnotes of `st` that hold it. Returns whether any did. +fn replace_in_tables_and_notes(st: &mut designcraft_doc::Story, i: usize, plan: &StoryPlan) -> bool { + let mut nested = false; + for (id, v) in &plan.tables { + if let Some(t) = st.tables.get_mut(id) + && t.cells.iter().any(|c| holds(&c.text, i, 1)) + { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, v, 1); + } + nested = true; + } + } + for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) { + if holds(¬e.text, i, 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1); + nested = true; + } + } + nested +} + /// Nesting limit for tables in cells and notes (hostile documents). const MAX_DEPTH: usize = 32; @@ -173,187 +431,4 @@ fn define(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - /// A parent-page page-number marker follows Numbering & Section Options: changing a section's - /// start number renames its pages without changing their index or the number of sections, and - /// the marker composed for a page shows the new name. - #[test] - fn parent_page_number_follows_section_renumbering() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) - .unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); - let shown = |s: &Session, page: usize| -> usize { - let d = s.doc().unwrap().doc.clone(); - let cs = s.cache.get(&d, sid, Some(page)); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(shown(&s, 2), 1, "page 3"); - s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); - assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); - assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); - } - - /// A section marker character shows the marker text of the section of the page it is composed - /// for: on each document page a parent-page frame is shown on, and in a frame on a document - /// page. A section without marker text shows nothing; the parent page itself shows "Section". - #[test] - fn section_marker_shows_the_pages_section_marker() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); - s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); - let marker = designcraft_doc::story::SECTION_MARKER.to_string(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) - .unwrap(); - let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); - let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let shown = |s: &Session, sid, page: Option| -> usize { - let d = s.doc().unwrap().doc.clone(); - let cs = s.cache.get(&d, sid, page); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); - assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); - assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); - assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); - assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); - // Editing the marker recomposes. - s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); - assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); - assert_eq!(shown(&s, own, None), "Appendix".len()); - } - - /// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running - /// header is composed for the page it is shown on, not the first page with that name. - #[test] - fn running_header_on_parent_page_with_repeated_page_numbers() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); - s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); - let names: Vec = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); - assert_eq!(names, ["1", "2", "1", "2"]); - s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); - // The only heading is on the third page (the second section's page 1). - let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); - s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) - .unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(page)); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - assert_eq!(shown(0), 0, "no heading on or before the first page"); - assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); - assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); - } - - /// Deleting a variable flattens its instances in table cells too and moves the instances of - /// later variables down, so cells keep showing the same text. - #[test] - fn delete_variable_reaches_table_cells() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); - let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; - let a = s.doc().unwrap().doc.variable("A").unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); - let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); - s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); - s.execute("variables.delete", &json!({"name": "A"})).unwrap(); - let d = &s.doc().unwrap().doc; - let t = d.story(sid).unwrap().tables.values().next().unwrap(); - let nb = d.variable("B").unwrap(); - assert_eq!(t.cells[0].text.text, format!("Alpha|{}", var_char(nb).unwrap())); - } - - /// Deleting a variable flattens its instances in footnotes and endnotes too and moves the - /// instances of later variables down. - #[test] - fn delete_variable_reaches_notes() { - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); - let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; - let a = s.doc().unwrap().doc.variable("A").unwrap(); - let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "x"})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); - s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); - s.execute("footnote.insert", &json!({"text": text})).unwrap(); - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("endnote.insert", &json!({"text": text})).unwrap(); - s.execute("variables.delete", &json!({"name": "A"})).unwrap(); - let d = &s.doc().unwrap().doc; - let st = d.story(sid).unwrap(); - let want = format!("Alpha|{}", var_char(d.variable("B").unwrap()).unwrap()); - assert_eq!(st.notes[0].text.text, want); - assert_eq!(st.endnotes[0].text.text, want); - } - - #[test] - fn running_header_on_parent_page_follows_headings() { - let mut s = Session::new(); - s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); - s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); - // Headings on pages 1 and 3 (page 2 carries page 1's). - for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { - let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; - let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": text})).unwrap(); - let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); - s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); - } - s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); - s.execute("variables.define", &json!({"name": "Total", "type": "custom", "text": "of "})).unwrap(); - // A header frame on the parent with " · / ". - let r = s - .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) - .unwrap(); - let fid = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); - let sid = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; - s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); - s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); - s.execute("text.insert", &json!({"text": " · "})).unwrap(); - s.execute("variables.insert", &json!({"name": "Last Page Number"})).unwrap(); - let d = s.doc().unwrap().doc.clone(); - assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); - // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". - let shown = |page: usize| -> usize { - let cs = s.cache.get(&d, sid, Some(page)); - cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() - }; - let (p1, p2, p3) = (shown(0), shown(1), shown(2)); - assert_eq!(p1, p2, "page 2 carries page 1's header"); - assert_eq!(p3, p1 + 2, "Epsilon is two letters longer than Alpha"); - let idx = s.cache.running_index(&d); - assert_eq!(idx.value("Heading", false, Default::default(), 0), "Alpha"); - assert_eq!(idx.value("Heading", false, Default::default(), 1), "Alpha"); - assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); - let list = s.execute("variables.list", &json!({})).unwrap(); - assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); - // Deleting a variable flattens its instances to text. - s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); - assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); - } -} +mod tests; diff --git a/crates/engine/src/cmd/variables/tests.rs b/crates/engine/src/cmd/variables/tests.rs new file mode 100644 index 000000000..4a4832c84 --- /dev/null +++ b/crates/engine/src/cmd/variables/tests.rs @@ -0,0 +1,356 @@ +use super::*; + +/// A parent-page page-number marker follows Numbering & Section Options: changing a section's +/// start number renames its pages without changing their index or the number of sections, and +/// the marker composed for a page shows the new name. +#[test] +fn parent_page_number_follows_section_renumbering() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + let marker = designcraft_doc::story::PAGE_NUMBER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, marker); + let shown = |s: &Session, page: usize| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, 2), 1, "page 3"); + s.execute("layout.section", &json!({"page": 1, "startNumber": 100})).unwrap(); + assert_eq!(s.doc().unwrap().doc.page_name(2), "102"); + assert_eq!(shown(&s, 2), 3, "page 102 after renumbering"); +} + +/// A section marker character shows the marker text of the section of the page it is composed +/// for: on each document page a parent-page frame is shown on, and in a frame on a document +/// page. A section without marker text shows nothing; the parent page itself shows "Section". +#[test] +fn section_marker_shows_the_pages_section_marker() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1, "marker": "Intro"})).unwrap(); + let marker = designcraft_doc::story::SECTION_MARKER.to_string(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": marker})) + .unwrap(); + let parent = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let sp = s.doc().unwrap().doc.page_loc(3).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": marker})).unwrap(); + let own = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let shown = |s: &Session, sid, page: Option| -> usize { + let d = s.doc().unwrap().doc.clone(); + let cs = s.cache.get(&d, sid, page); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(&s, parent, Some(0)), 0, "the first section has no marker"); + assert_eq!(shown(&s, parent, Some(2)), "Intro".len()); + assert_eq!(shown(&s, parent, Some(3)), "Intro".len()); + assert_eq!(shown(&s, own, None), "Intro".len(), "a frame on a document page"); + assert_eq!(shown(&s, parent, None), "Section".len(), "the parent page itself"); + // Editing the marker recomposes. + s.execute("layout.section", &json!({"page": 3, "marker": "Appendix"})).unwrap(); + assert_eq!(shown(&s, parent, Some(2)), "Appendix".len()); + assert_eq!(shown(&s, own, None), "Appendix".len()); +} + +/// Two sections that both number from 1 repeat page names ("1, 2, 1, 2"). A parent-page running +/// header is composed for the page it is shown on, not the first page with that name. +#[test] +fn running_header_on_parent_page_with_repeated_page_numbers() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 4, "facingPages": false})).unwrap(); + s.execute("layout.section", &json!({"page": 3, "startNumber": 1})).unwrap(); + let names: Vec = (0..4).map(|i| s.doc().unwrap().doc.page_name(i)).collect(); + assert_eq!(names, ["1", "2", "1", "2"]); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // The only heading is on the third page (the second section's page 1). + let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": "Epsilon"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + assert_eq!(shown(0), 0, "no heading on or before the first page"); + assert_eq!(shown(2), "Epsilon".len(), "the third page (named 1) shows its own heading"); + assert_eq!(shown(3), "Epsilon".len(), "the fourth page (named 2) carries it"); +} + +/// Three pages with "Alpha" in the Heading style on page 1 and "Epsilon" on page 3, a +/// `Chapter Head` running header on Heading, and a text frame on pages 1 and 3 that each hold +/// "|" (two instances). Returns the two frames' stories. +fn running_heads() -> (Session, designcraft_doc::StoryId, designcraft_doc::StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + let frame = |s: &mut Session, page: usize, y: f64, text: &str| { + let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, y, 400, y + 60.0], "content": "text", "text": text})).unwrap(); + s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story + }; + for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { + let sid = frame(&mut s, page, 200.0, text); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let both = format!("{c}|{c}"); + let (p1, p3) = (frame(&mut s, 0, 400.0, &both), frame(&mut s, 2, 400.0, &both)); + (s, p1, p3) +} + +/// Convert Variable to Text turns the selected instance into the value it shows on its own +/// page (Epsilon on page 3, not page 1's Alpha); the other instance and the variable stay. +#[test] +fn convert_variable_to_text_uses_the_instances_page() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let second = c.len_utf8() + 1; + s.execute("text.select", &json!({"story": p3.0, "anchor": second, "focus": second + c.len_utf8()})).unwrap(); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p3).unwrap().text, format!("{c}|Epsilon"), "only the selected instance"); + assert_eq!(d.story(p1).unwrap().text, format!("{c}|{c}")); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (second, second + "Epsilon".len()), "the converted text stays selected"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(p3).unwrap().text, format!("{c}|{c}"), "one undo step"); +} + +/// The menu item is enabled only for a selection that holds an instance: not a caret, not +/// text without one. +#[test] +fn convert_variable_to_text_needs_a_selected_instance() { + let (mut s, p1, _) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let bar = c.len_utf8(); + let enabled = |s: &Session| (crate::find_command("variables.convertToText").unwrap().enabled)(s).is_ok(); + for (anchor, focus) in [(0, 0), (bar, bar + 1)] { + s.execute("text.select", &json!({"story": p1.0, "anchor": anchor, "focus": focus})).unwrap(); + assert!(!enabled(&s), "{anchor}..{focus}"); + } + s.execute("text.select", &json!({"story": p1.0, "anchor": 0, "focus": bar})).unwrap(); + assert!(enabled(&s)); +} + +/// A story threaded from a frame on page 1, which holds only "Lead", to a frame on page 3, +/// where the caret is left at the end of "Body". A cell's or a note's own byte 0 read in this +/// story is on page 1, its anchor on page 3. +fn threaded(s: &mut Session) -> designcraft_doc::StoryId { + let sp1 = s.doc().unwrap().doc.page_loc(0).unwrap().0; + let sp3 = s.doc().unwrap().doc.page_loc(2).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp1, "rect": [72, 500, 400, 520], "content": "text", "text": "Lead\nBody"})).unwrap(); + let from = r["id"].as_u64().unwrap(); + s.execute("text.thread", &json!({"from": from, "rect": [72, 500, 400, 700], "spread": sp3})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let sid = d.item(designcraft_doc::ItemId(from)).unwrap().text_frame().unwrap().story; + let cs = s.cache.get(&d, sid, None); + let page = |pos| designcraft_compose::vars::page_of(&d, &cs, pos); + assert_eq!((page(0), page("Lead\nB".len())), (Some(0), Some(2)), "Lead on page 1, Body on page 3"); + s.execute("text.select", &json!({"story": sid.0, "anchor": "Lead\nBody".len(), "focus": "Lead\nBody".len()})).unwrap(); + sid +} + +/// An instance selected in a table cell becomes the value on the page of the table's anchor. +#[test] +fn convert_variable_to_text_in_a_table_cell() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + let cell = designcraft_doc::CellAddr { table, row: 0, col: 0 }; + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: c.len_utf8(), frame: None, cell: Some(cell) }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.text_story(sid, Some(cell)).unwrap().text, "Epsilon"); +} + +/// An instance selected in a footnote becomes the value on the page of the note's reference. +#[test] +fn convert_variable_to_text_in_a_footnote() { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + let id = s.doc().unwrap().doc.story(sid).unwrap().notes[0].id; + let note = designcraft_doc::CellAddr::footnote(id); + let at = s.doc().unwrap().doc.text_story(sid, Some(note)).unwrap().text.find(c).unwrap(); + s.doc_mut().unwrap().selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { + story: sid, + anchor: at, + focus: at + c.len_utf8(), + frame: None, + cell: Some(note), + }); + s.execute("variables.convertToText", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let text = &d.text_story(sid, Some(note)).unwrap().text; + assert!(!text.contains(c) && text.ends_with("Epsilon"), "{text:?}"); +} + +/// Text Variables › Convert to Text turns every instance of the variable into the value on +/// its page, in table cells, footnotes and endnotes on the page of their anchor, and keeps the +/// variable. +#[test] +fn convert_all_instances_to_text() { + let (mut s, p1, p3) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let at_end = |s: &mut Session| { + let n = s.doc().unwrap().doc.story(sid).unwrap().len(); + s.execute("text.select", &json!({"story": sid.0, "anchor": n, "focus": n})).unwrap(); + }; + s.execute("footnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("endnote.insert", &json!({"text": c.to_string()})).unwrap(); + at_end(&mut s); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": c.to_string()})).unwrap(); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.story(p1).unwrap().text, "Alpha|Alpha"); + assert_eq!(d.story(p3).unwrap().text, "Epsilon|Epsilon"); + let st = d.story(sid).unwrap(); + assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "Epsilon", "table cell"); + assert!(st.notes[0].text.text.ends_with("Epsilon"), "footnote: {:?}", st.notes[0].text.text); + assert!(st.endnotes[0].text.text.ends_with("Epsilon"), "endnote: {:?}", st.endnotes[0].text.text); + assert!(d.variable("Chapter Head").is_some(), "the variable stays defined"); +} + +/// A caret in a table cell or a footnote after an instance stays after the value Convert to +/// Text writes there, so typing lands after it. +#[test] +fn convert_all_keeps_the_caret_in_a_cell_or_a_footnote() { + for footnote in [false, true] { + let (mut s, ..) = running_heads(); + let c = var_char(s.doc().unwrap().doc.variable("Chapter Head").unwrap()).unwrap(); + let sid = threaded(&mut s); + let text = format!("{c}|"); + let at = if footnote { + s.execute("footnote.insert", &json!({"text": text})).unwrap(); + designcraft_doc::CellAddr::footnote(s.doc().unwrap().doc.story(sid).unwrap().notes[0].id) + } else { + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); + let table = *s.doc().unwrap().doc.story(sid).unwrap().tables.keys().next().unwrap(); + designcraft_doc::CellAddr { table, row: 0, col: 0 } + }; + let n = s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text.len(); + s.doc_mut().unwrap().selection = + designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: n, focus: n, frame: None, cell: Some(at) }); + s.execute("variables.convertAllToText", &json!({"name": "Chapter Head"})).unwrap(); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + let text = &s.doc().unwrap().doc.text_story(sid, Some(at)).unwrap().text; + assert!(text.ends_with("Epsilon|x"), "footnote {footnote}: {text:?}"); + } +} + +/// Deleting a variable flattens its instances in table cells too and moves the instances of +/// later variables down, so cells keep showing the same text. +#[test] +fn delete_variable_reaches_table_cells() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); + let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; + let a = s.doc().unwrap().doc.variable("A").unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": ""})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap(); + let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); + s.execute("table.setCell", &json!({"row": 0, "col": 0, "text": text})).unwrap(); + s.execute("variables.delete", &json!({"name": "A"})).unwrap(); + let d = &s.doc().unwrap().doc; + let t = d.story(sid).unwrap().tables.values().next().unwrap(); + let nb = d.variable("B").unwrap(); + assert_eq!(t.cells[0].text.text, format!("Alpha|{}", var_char(nb).unwrap())); +} + +/// Deleting a variable flattens its instances in footnotes and endnotes too and moves the +/// instances of later variables down. +#[test] +fn delete_variable_reaches_notes() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("variables.define", &json!({"name": "A", "type": "custom", "text": "Alpha"})).unwrap(); + let b = s.execute("variables.define", &json!({"name": "B", "type": "custom", "text": "Beta"})).unwrap()["index"].as_u64().unwrap() as usize; + let a = s.doc().unwrap().doc.variable("A").unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "x"})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + let text = format!("{}|{}", var_char(a).unwrap(), var_char(b).unwrap()); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + s.execute("footnote.insert", &json!({"text": text})).unwrap(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("endnote.insert", &json!({"text": text})).unwrap(); + s.execute("variables.delete", &json!({"name": "A"})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(sid).unwrap(); + let want = format!("Alpha|{}", var_char(d.variable("B").unwrap()).unwrap()); + assert_eq!(st.notes[0].text.text, want); + assert_eq!(st.endnotes[0].text.text, want); +} + +#[test] +fn running_header_on_parent_page_follows_headings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Heading"})).ok(); + // Headings on pages 1 and 3 (page 2 carries page 1's). + for (page, text) in [(0, "Alpha"), (2, "Epsilon")] { + let sp = s.doc().unwrap().doc.page_loc(page).unwrap().0; + let r = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 200, 400, 260], "content": "text", "text": text})).unwrap(); + let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 1})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Heading"})).unwrap(); + } + s.execute("variables.define", &json!({"name": "Chapter Head", "type": "runningHeader", "style": "Heading"})).unwrap(); + s.execute("variables.define", &json!({"name": "Total", "type": "custom", "text": "of "})).unwrap(); + // A header frame on the parent with " · / ". + let r = s + .execute("frame.create", &json!({"spread": {"kind": "parent", "index": 0}, "rect": [72, 30, 500, 60], "content": "text", "text": ""})) + .unwrap(); + let fid = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let sid = s.doc().unwrap().doc.item(fid).unwrap().text_frame().unwrap().story; + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("variables.insert", &json!({"name": "Chapter Head"})).unwrap(); + s.execute("text.insert", &json!({"text": " · "})).unwrap(); + s.execute("variables.insert", &json!({"name": "Last Page Number"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(matches!(d.find(fid).unwrap().spread, designcraft_doc::SpreadRef::Parent(0))); + // Visible glyphs of the header composed for each page: "Alpha · 3" vs "Epsilon · 3". + let shown = |page: usize| -> usize { + let cs = s.cache.get(&d, sid, Some(page)); + cs.frames.iter().flat_map(|f| f.lines.iter()).map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum() + }; + let (p1, p2, p3) = (shown(0), shown(1), shown(2)); + assert_eq!(p1, p2, "page 2 carries page 1's header"); + assert_eq!(p3, p1 + 2, "Epsilon is two letters longer than Alpha"); + let idx = s.cache.running_index(&d); + assert_eq!(idx.value("Heading", false, Default::default(), 0), "Alpha"); + assert_eq!(idx.value("Heading", false, Default::default(), 1), "Alpha"); + assert_eq!(idx.value("Heading", false, Default::default(), 2), "Epsilon"); + let list = s.execute("variables.list", &json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|v| v["name"] == "Last Page Number" && v["value"] == "3")); + // Deleting a variable flattens its instances to text. + s.execute("variables.delete", &json!({"name": "Last Page Number"})).unwrap(); + assert!(s.doc().unwrap().doc.story(sid).unwrap().text.ends_with(" · 3")); +} diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 4f9a17c6f..d26e0ef8f 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -1,15 +1,17 @@ //! The folders automation may read and write. //! //! `designcraft-cli mcp --automation-read-root --automation-write-root ` confines the -//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command -//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! files an agent's commands reach; each flag may be given more than once (#1552). [`AutomationRoots`] +//! holds the folders of each kind. While they are in force, every path a command reads goes through +//! [`check_read`] and every path it writes through [`check_write`]: //! //! - The path is made absolute against the working directory, as the file system reads it. Its //! links are followed: for a file that doesn't exist yet, the links of its nearest existing //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so //! is a link that leads nowhere). -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. +//! - The result must lie inside one of the roots of its kind, compared by whole components +//! (`/work2` isn't inside `/work`), without regard to case on Windows. Two roots don't make their +//! common parent one. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after @@ -32,11 +34,11 @@ pub enum Access { Write, } -/// The folders automation may read and write; a root left out grants no access of its kind. +/// The folders automation may read and write; a kind with no root grants no access of its kind. #[derive(Debug)] pub struct AutomationRoots { - read: Option, - write: Option, + read: Vec, + write: Vec, } #[derive(Debug)] @@ -62,47 +64,61 @@ impl Root { } impl AutomationRoots { - /// The roots `read` and `write`, which must be existing folders; `None` when neither is given - /// (nothing is confined then). - pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { - if read.is_none() && write.is_none() { + /// The roots `read` and `write`, which must be existing folders (one given twice counts once); + /// `None` when neither kind has any (nothing is confined then). + pub fn new(read: &[&Path], write: &[&Path]) -> Result>, String> { + if read.is_empty() && write.is_empty() { return Ok(None); } - let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; - let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; - Ok(Some(Arc::new(Self { read, write }))) + let open = |paths: &[&Path], access: Access| -> Result, String> { + let mut roots: Vec = Vec::with_capacity(paths.len()); + for path in paths { + let root = Root::open(path, access)?; + if !roots.iter().any(|r| r.resolved == root.resolved) { + roots.push(root); + } + } + Ok(roots) + }; + Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? }))) } - /// The read root, absolute with its links followed. - pub fn read_root(&self) -> Option<&Path> { - self.read.as_ref().map(|r| r.shown.as_path()) + /// The roots for people: `read A, B; write C` (`none` for a kind without any), absolute with + /// their links followed. + pub fn describe(&self) -> String { + let list = |roots: &[Root]| { + if roots.is_empty() { + return "none".to_string(); + } + roots.iter().map(|r| r.shown.display().to_string()).collect::>().join(", ") + }; + format!("read {}; write {}", list(&self.read), list(&self.write)) } - /// The write root, absolute with its links followed. - pub fn write_root(&self) -> Option<&Path> { - self.write.as_ref().map(|r| r.shown.as_path()) - } - - /// May the file or folder at `path` be read (or written)? `Err` says why not. + /// May the file or folder at `path` be read (or written)? It must lie inside one of the roots + /// of that kind. `Err` says why not. pub fn check(&self, path: impl AsRef, access: Access) -> Result<(), String> { let path = path.as_ref(); - let root = match access { - Access::Read => self.read.as_ref(), - Access::Write => self.write.as_ref(), + let roots = match access { + Access::Read => &self.read, + Access::Write => &self.write, }; - let Some(Root { resolved: folder, shown }) = root else { + if roots.is_empty() { return match access { Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {}", path.display())), Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {}", path.display())), }; - }; - if inside(&resolve(path)?, folder) { + } + let resolved = resolve(path)?; + if roots.iter().any(|r| inside(&resolved, &r.resolved)) { return Ok(()); } - let root = shown.display(); - match access { - Access::Read => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), - Access::Write => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + let shown: Vec = roots.iter().map(|r| r.shown.display().to_string()).collect(); + match (shown.as_slice(), access) { + ([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {}", path.display())), + ([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {}", path.display())), + (_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {}", shown.join(", "), path.display())), + (_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {}", shown.join(", "), path.display())), } } } @@ -279,12 +295,24 @@ mod tests { } std::fs::write(base.join("in/a.designcraft"), "x").unwrap(); std::fs::write(base.join("outside/secret.designcraft"), "x").unwrap(); - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } fn s(p: PathBuf) -> String { - p.to_string_lossy().into_owned() + let text = p.to_string_lossy(); + if !cfg!(windows) { + return text.into_owned(); + } + // A canonical path on Windows has a `\\?\` prefix, under which `..` is not a name: + // send ordinary paths, as the roots show them. + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{rest}"); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => rest.to_owned(), + None => text.into_owned(), + } } #[test] @@ -339,18 +367,18 @@ mod tests { #[test] fn a_missing_root_is_an_error_and_none_is_no_confinement() { let (base, _) = folders("missing"); - let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err(); assert!(e.starts_with("cannot open the automation read root"), "{e}"); - let e = AutomationRoots::new(None, Some(&base.join("in/a.designcraft"))).unwrap_err(); + let e = AutomationRoots::new(&[], &[&base.join("in/a.designcraft")]).unwrap_err(); assert!( e.starts_with("cannot open the automation write root") && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), "{e}" ); - assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); - assert!(AutomationRoots::new(None, None).unwrap().is_none()); + assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(&[], &[]).unwrap().is_none()); // One root alone grants only its own access. - let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap(); let e = read_only.check(s(base.join("in/x.designcraft")), Access::Write).unwrap_err(); assert!(e.contains("write authority is absent"), "{e}"); let _ = std::fs::remove_dir_all(base); @@ -387,11 +415,127 @@ mod tests { assert_eq!(roots.check(s(base.join("in/alias/x.designcraft")), Access::Read), Ok(())); // A root given through a link is the folder it leads to. assert!(made(&base.join("in"), &base.join("in-link"))); - let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap(); assert_eq!(linked.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); let _ = std::fs::remove_dir_all(base); } + /// `in` and `shared` (read roots), `out` and `scratch` (write roots) beside `outside`, the + /// siblings `in2` and `scratch2` whose names start like a root's, and a file in their parent. + /// The folder is returned resolved (`/var` is `/private/var` on macOS), as the roots show it, so + /// a path that passes only because it was resolved is the case being tested and nothing else. + /// It has no `\\?\` prefix on Windows, where the roots are shown without it and `..` is not + /// resolved in a path that has it. + fn several(name: &str) -> (PathBuf, Arc) { + let (base, _) = folders(name); + let base = plain(std::fs::canonicalize(base).unwrap()); + for d in ["shared", "scratch", "scratch2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + for f in ["shared/b.designcraft", "in2/c.designcraft", "parent.designcraft"] { + std::fs::write(base.join(f), "x").unwrap(); + } + let roots = AutomationRoots::new(&[&base.join("in"), &base.join("shared")], &[&base.join("out"), &base.join("scratch")]).unwrap().unwrap(); + (base, roots) + } + + /// #1552: several roots of a kind: a path inside any of them passes, one inside none is refused + /// naming them all, and read and write stay separate. + #[test] + fn several_roots_of_a_kind_each_grant_their_access() { + let (base, roots) = several("several"); + assert_eq!(roots.check(s(base.join("in/a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("shared/b.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("out/x.png")), Access::Write), Ok(())); + assert_eq!(roots.check(s(base.join("scratch/new/x.png")), Access::Write), Ok(())); + let e = roots.check(s(base.join("outside/secret.designcraft")), Access::Read).unwrap_err(); + let listed = format!("outside the read roots {}, {}: ", s(base.join("in")), s(base.join("shared"))); + assert!(e.starts_with("automation path rejected: ") && e.contains(&listed), "{e}"); + assert!(roots.check(s(base.join("outside/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Two sibling roots don't make their parent a root. + assert!(roots.check(s(base.join("parent.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.clone()), Access::Read).is_err()); + assert!(roots.check(s(base.join("x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Read and write stay separate: a read root is not a write root, nor the other way round. + assert!(roots.check(s(base.join("shared/b.designcraft")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(s(base.join("in/x.png")), Access::Write).is_err()); + assert!(roots.check(s(base.join("scratch/x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("out")), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn several_roots_compare_whole_folder_names() { + let (base, roots) = several("several-prefix"); + assert!(roots.check(s(base.join("in2/c.designcraft")), Access::Read).unwrap_err().contains("outside the read roots"), "in2 is not inside in"); + assert!(roots.check(format!("{}2/x.designcraft", s(base.join("shared"))), Access::Read).is_err()); + assert!( + roots.check(s(base.join("scratch2/x.png")), Access::Write).unwrap_err().contains("outside the write roots"), + "scratch2 is not inside scratch" + ); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_between_several_roots() { + let (base, roots) = several("several-dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = |root: &str, rest: &str| format!("{}{sep}..{sep}{rest}", s(base.join(root))); + // `..` out of one root is outside, also when it lands next to another root. + assert!(roots.check(up("shared", &format!("outside{sep}secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(up("shared", "parent.designcraft"), Access::Read).is_err()); + assert!(roots.check(up("scratch", &format!("outside{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // `..` from one root into another of the same kind stays inside. + assert_eq!(roots.check(up("shared", &format!("in{sep}a.designcraft")), Access::Read), Ok(())); + assert_eq!(roots.check(up("scratch", &format!("out{sep}x.png")), Access::Write), Ok(())); + // ...but not into a root of the other kind. + assert!(roots.check(up("scratch", &format!("in{sep}x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(up("shared", &format!("out{sep}x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn several_roots_are_each_checked_and_counted_once() { + let (base, _) = folders("several-open"); + // A missing folder among several is an error, wherever it stands. + for read in [[base.join("in"), base.join("nope")], [base.join("nope"), base.join("in")]] { + let e = AutomationRoots::new(&[&read[0], &read[1]], &[]).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root") && e.contains("nope"), "{e}"); + } + let e = AutomationRoots::new(&[&base.join("in")], &[&base.join("out"), &base.join("in/a.designcraft")]).unwrap_err(); + assert!(e.starts_with("cannot open the automation write root"), "{e}"); + // A folder given twice (here once through `..`) counts once. + let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/.."), &base.join("in")], &[]).unwrap().unwrap(); + let real = plain(std::fs::canonicalize(&base).unwrap()); + assert_eq!(twice.describe(), format!("read {}; write none", s(real.join("in")))); + let e = twice.check(s(base.join("outside/x")), Access::Read).unwrap_err(); + assert!(e.contains("outside the read root ") && !e.contains("roots"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + /// A link inside one root that leads into another root of the same kind stays inside; one that + /// leads out of all of them, or into a root of the other kind, is outside. + #[cfg(unix)] + #[test] + fn links_between_several_roots() { + let (base, roots) = several("several-links"); + let link = |target: &str, at: &str| std::os::unix::fs::symlink(base.join(target), base.join(at)).unwrap(); + link("shared/b.designcraft", "in/to-shared.designcraft"); + link("outside/secret.designcraft", "shared/leak.designcraft"); + link("", "shared/up"); + link("out", "in/to-out"); + link("outside", "scratch/away"); + link("in", "scratch/to-in"); + assert_eq!(roots.check(s(base.join("in/to-shared.designcraft")), Access::Read), Ok(())); + assert!(roots.check(s(base.join("shared/leak.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("shared/up/outside/secret.designcraft")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("shared/up/parent.designcraft")), Access::Read).is_err()); + assert!(roots.check(s(base.join("in/to-out/x.png")), Access::Read).unwrap_err().contains("outside the read roots")); + assert!(roots.check(s(base.join("scratch/away/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + assert!(roots.check(s(base.join("scratch/to-in/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + let _ = std::fs::remove_dir_all(base); + } + #[cfg(windows)] #[test] fn windows_ignores_case_and_refuses_devices() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 545c3fac3..58301e9e6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -20,6 +20,7 @@ pub mod math; pub mod recovery; pub mod sample; pub mod script; +pub mod separations; mod tooling; use std::sync::Arc; @@ -33,7 +34,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use designcraft_compose as compose; pub use designcraft_doc as doc; pub use designcraft_tools as tools; -pub use tooling::{SnapView, UiRequest, ViewInfo}; +pub use tooling::{ImeMutation, ImeTextContext, SnapView, UiRequest, ViewInfo, ime_delete_ranges}; #[derive(Debug, thiserror::Error)] pub enum EngineError { @@ -101,6 +102,9 @@ pub struct DocState { /// Editing parent spreads (Pages panel double-click on a parent). pub editing_parents: bool, pub uid: u64, + /// Changes whenever this document state is recreated, even if [`Self::uid`] is preserved + /// (File › Revert keeps the uid for recovery but replaces the document). + pub identity_epoch: u64, /// Recovery entries this state actually opened or published, for repeated-open and cleanup. /// Bounded, deduplicated checkpoint addresses; never inferred from uid alone. pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>, @@ -141,10 +145,12 @@ fn next_revision() -> u64 { } impl DocState { + /// Initialize saved/live document identity, revision, epoch, active default layer, empty history with limit 1,000 and the initial path. pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.default_layer(); let doc = Arc::new(doc); let revision = next_revision(); + let identity_epoch = NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); DocState { saved_doc: doc.clone(), doc, @@ -156,7 +162,8 @@ impl DocState { active_layer, interaction: None, editing_parents: false, - uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + uid: identity_epoch, + identity_epoch, recovery_entries: Vec::new(), preview_stash: None, preview_record: None, @@ -756,19 +763,3 @@ pub(crate) use push_undo as record_undo; #[cfg(test)] mod tests; -#[cfg(test)] -mod tests_autosize; -#[cfg(test)] -mod tests_file_guard; -#[cfg(test)] -mod tests_idml; -#[cfg(test)] -mod tests_ime; -#[cfg(test)] -mod tests_linked_paths; -#[cfg(test)] -mod tests_params; -#[cfg(test)] -mod tests_roots; -#[cfg(test)] -mod tests_table; diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs index 16ef7e234..468ce1c5a 100644 --- a/crates/engine/src/math.rs +++ b/crates/engine/src/math.rs @@ -59,7 +59,7 @@ impl MBox { } } -#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(test, derive(Debug, PartialEq))] enum Node { /// A character run: identifiers italic, numbers and symbols upright. Sym(String, Class), @@ -79,6 +79,54 @@ enum Node { Fenced(String, Box, String), } +// Empty a node before field drop glue can follow its children recursively. This also covers +// replaced scripts and partially built parser continuations during unwinding. +impl Node { + /// Move every child link into `pending`, draining `Group` children and replacing boxed children with empty groups, so field drop glue never walks a deep subtree. + fn detach_children(&mut self, pending: &mut Vec) { + /// Detach one child into `pending`, leaving an empty group in its slot. + fn detach(child: &mut Node, pending: &mut Vec) { + pending.push(std::mem::replace(child, Node::Group(Vec::new()))); + } + match self { + Self::Group(nodes) => pending.append(nodes), + Self::Frac(a, b) => { + detach(a, pending); + detach(b, pending); + } + Self::Sqrt(index, body) => { + if let Some(index) = index { + detach(index, pending); + } + detach(body, pending); + } + Self::Scripts { base, sup, sub } => { + detach(base, pending); + if let Some(sup) = sup { + detach(sup, pending); + } + if let Some(sub) = sub { + detach(sub, pending); + } + } + Self::Fenced(_, body, _) => detach(body, pending), + Self::Sym(_, _) | Self::Text(_) | Self::Space(_) | Self::Big(_) => {} + } + } +} + +impl Drop for Node { + /// Drain an explicit worklist, emptying each node before normal drop glue, so deep or replaced ASTs drop without stack overflow. + fn drop(&mut self) { + let mut pending = Vec::new(); + self.detach_children(&mut pending); + while let Some(mut node) = pending.pop() { + node.detach_children(&mut pending); + // Its own Drop and boxed field glue now see only empty leaves, never this subtree. + } + } +} + #[derive(Clone, Copy, Debug, PartialEq)] enum Class { Ident, @@ -160,15 +208,13 @@ fn big_op(name: &str) -> Option<&'static str> { }) } -/// Deepest expression nesting parsed. Recursive descent must not overflow the stack on hostile -/// input (`\frac\frac…`, `{{{{…`, `\left(\left(…`); past the cap groups stop descending. +/// Deepest logical expression nesting parsed. Heap continuations keep this full budget safe on +/// the default native thread stack; past the cap groups retain their existing empty result. const MAX_NESTING: usize = 256; struct Parser<'a> { s: &'a [char], i: usize, - /// Enclosing group depth, at most [`MAX_NESTING`]. - depth: usize, } impl Parser<'_> { @@ -225,21 +271,6 @@ impl Parser<'_> { } out } - fn arg(&mut self) -> Node { - if self.depth >= MAX_NESTING { - return Node::Group(Vec::new()); - } - self.depth += 1; - self.skip_ws(); - let n = if self.peek() == Some('{') { - self.i += 1; - Node::Group(self.list(Some('}'))) - } else { - self.atom().unwrap_or(Node::Group(vec![])) - }; - self.depth -= 1; - n - } fn delimiter(&mut self) -> String { self.skip_ws(); match self.peek() { @@ -265,166 +296,297 @@ impl Parser<'_> { None => String::new(), } } - fn atom(&mut self) -> Option { - self.skip_ws(); - let c = self.peek()?; - self.i += 1; - Some(match c { - '{' => Node::Group(self.list(Some('}'))), - '\\' => { - let name = self.command(); - match name.as_str() { - "frac" | "dfrac" | "tfrac" => { - let a = self.arg(); - let b = self.arg(); - Node::Frac(Box::new(a), Box::new(b)) - } - "sqrt" => { - self.skip_ws(); - let idx = (self.peek() == Some('[')).then(|| { - self.i += 1; - let mut s = String::new(); - while let Some(c) = self.peek() { - self.i += 1; - if c == ']' { - break; - } - s.push(c); - } - Box::new(parse_at(&s, self.depth + 1)) - }); - let a = self.arg(); - Node::Sqrt(idx, Box::new(a)) - } - "text" | "mathrm" | "textrm" | "operatorname" => Node::Text(self.raw_group()), - "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => Node::Text(name), - "," => Node::Space(3.0 / 18.0), - ":" | ">" => Node::Space(4.0 / 18.0), - ";" => Node::Space(5.0 / 18.0), - "quad" => Node::Space(1.0), - "qquad" => Node::Space(2.0), - "!" => Node::Space(-3.0 / 18.0), - " " => Node::Space(0.25), - "left" => { - let open = self.delimiter(); - let body = self.list_until_right(); - let close = self.delimiter(); - Node::Fenced(open, Box::new(Node::Group(body)), close) - } - "{" => Node::Sym("{".into(), Class::Punct), - "}" => Node::Sym("}".into(), Class::Punct), - "%" | "$" | "#" | "&" | "_" => Node::Sym(name, Class::Plain), - n => match (big_op(n), symbol(n)) { - (Some(op), _) => Node::Big(op.into()), - (None, Some((s, cl))) => Node::Sym(s.into(), cl), - _ => Node::Text(format!("\\{n}")), - }, - } +} + +/// Continuations carry parser state on the heap instead of in native call frames. +enum ParseWork<'a> { + List { end: Option, depth: usize }, + Sequence { kind: SequenceKind, depth: usize, out: Vec }, + Append { kind: SequenceKind, depth: usize, out: Vec, group: bool }, + Arg { depth: usize }, + Atom { depth: usize }, + Script { kind: SequenceKind, depth: usize, out: Vec, up: bool }, + FractionNumerator { depth: usize }, + FractionDenominator { numerator: Node }, + SqrtIndex { outer: Parser<'a>, depth: usize }, + SqrtArgument { index: Option> }, + Fence { open: String }, + UntilRight { depth: usize }, + One { depth: usize }, + OneAtom { depth: usize }, +} + +#[derive(Clone, Copy)] +enum SequenceKind { + List(Option), + Fence, + One, +} + +/// Repeated scripts replace only their own slot, preserving the base and the opposite slot. +fn attach_script(mut base: Node, argument: Node, up: bool) -> Node { + if let Node::Scripts { sup, sub, .. } = &mut base { + let slot = if up { sup } else { sub }; + *slot = Some(Box::new(argument)); + base + } else if up { + Node::Scripts { base: Box::new(base), sup: Some(Box::new(argument)), sub: None } + } else { + Node::Scripts { base: Box::new(base), sup: None, sub: Some(Box::new(argument)) } + } +} + +impl<'a> Parser<'a> { + /// Run the parser as a heap worklist from a root `List`, so input nesting does not consume native stack. + fn parse(&mut self, depth: usize) -> Node { + let mut work = vec![ParseWork::List { end: None, depth }]; + let mut value = Node::Group(Vec::new()); + while let Some(next) = work.pop() { + match next { + ParseWork::List { end, depth } => Self::step_list(&mut work, &mut value, end, depth), + ParseWork::Sequence { kind, depth, out } => self.step_sequence(&mut work, &mut value, kind, depth, out), + ParseWork::Append { kind, depth, out, group } => Self::step_append(&mut work, &mut value, kind, depth, out, group), + ParseWork::Arg { depth } => self.step_arg(&mut work, &mut value, depth), + ParseWork::Atom { depth } => self.step_atom(&mut work, &mut value, depth), + ParseWork::Script { kind, depth, out, up } => Self::step_script(&mut work, &mut value, kind, depth, out, up), + ParseWork::FractionNumerator { depth } => Self::step_fraction_numerator(&mut work, &mut value, depth), + ParseWork::FractionDenominator { numerator } => Self::step_fraction_denominator(&mut value, numerator), + ParseWork::SqrtIndex { outer, depth } => self.step_sqrt_index(&mut work, &mut value, outer, depth), + ParseWork::SqrtArgument { index } => Self::step_sqrt_argument(&mut value, index), + ParseWork::Fence { open } => self.step_fence(&mut value, open), + ParseWork::UntilRight { depth } => Self::step_until_right(&mut work, &mut value, depth), + ParseWork::One { depth } => Self::step_one(&mut work, depth), + ParseWork::OneAtom { depth } => Self::step_one_atom(&mut work, &mut value, depth), } + } + value + } + + /// Start a list sequence, or yield an empty group when `depth` has reached `MAX_NESTING`. + fn step_list(work: &mut Vec>, value: &mut Node, end: Option, depth: usize) { + if depth >= MAX_NESTING { + *value = Node::Group(Vec::new()); + } else { + work.push(ParseWork::Sequence { kind: SequenceKind::List(end), depth: depth + 1, out: Vec::new() }); + } + } + + /// Consume list/fence terminators, route `^`/`_` to script attachment, append the next atom for list and fence sequences, or finish a `One` sequence after its single atom; fence sequences stop only at a real `\right`. + fn step_sequence(&mut self, work: &mut Vec>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec) { + self.skip_ws(); + let Some(c) = self.peek() else { + *value = Node::Group(out); + return; + }; + if let SequenceKind::List(Some(end)) = kind + && c == end + { + self.i += 1; + *value = Node::Group(out); + return; + } + if matches!(kind, SequenceKind::Fence) + && self.s.get(self.i..).is_some_and(|s| s.starts_with(&['\\', 'r', 'i', 'g', 'h', 't'])) + && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) + { + self.i += 6; + *value = Node::Group(out); + return; + } + if (c == '^' || c == '_') && !matches!(kind, SequenceKind::Fence) { + self.i += 1; + work.push(ParseWork::Script { kind, depth, out, up: c == '^' }); + work.push(ParseWork::Arg { depth }); + } else if matches!(kind, SequenceKind::One) { + *value = Node::Group(out); + } else { + let group = matches!(kind, SequenceKind::Fence); + work.push(ParseWork::Append { kind, depth, out: std::mem::take(&mut out), group }); + work.push(if group { ParseWork::One { depth: depth + 1 } } else { ParseWork::Atom { depth } }); + } + } + + /// Commit the completed atom or fence group into the pending sequence and resume it. + fn step_append(work: &mut Vec>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec, group: bool) { + if group { + if let Node::Group(nodes) = &mut *value { + out.append(nodes); + } + } else { + out.push(std::mem::replace(value, Node::Group(Vec::new()))); + } + work.push(ParseWork::Sequence { kind, depth, out }); + } + + /// Parse a braced list or one atom at `depth + 1`; a capped argument becomes an empty group. + fn step_arg(&mut self, work: &mut Vec>, value: &mut Node, depth: usize) { + if depth >= MAX_NESTING { + *value = Node::Group(Vec::new()); + return; + } + self.skip_ws(); + if self.peek() == Some('{') { + self.i += 1; + work.push(ParseWork::List { end: Some('}'), depth: depth + 1 }); + } else { + work.push(ParseWork::Atom { depth: depth + 1 }); + } + } + + /// Attach the completed argument as superscript or subscript, preserving the opposite slot of an existing script node. + fn step_script(work: &mut Vec>, value: &mut Node, kind: SequenceKind, depth: usize, mut out: Vec, up: bool) { + let argument = std::mem::replace(value, Node::Group(Vec::new())); + let base = out.pop().unwrap_or(Node::Group(Vec::new())); + out.push(attach_script(base, argument, up)); + work.push(ParseWork::Sequence { kind, depth, out }); + } + + /// Save the parsed numerator and queue the denominator argument. + fn step_fraction_numerator(work: &mut Vec>, value: &mut Node, depth: usize) { + let numerator = std::mem::replace(value, Node::Group(Vec::new())); + work.push(ParseWork::FractionDenominator { numerator }); + work.push(ParseWork::Arg { depth }); + } + + /// Build `Frac` from the saved numerator and the completed denominator. + fn step_fraction_denominator(value: &mut Node, numerator: Node) { + *value = Node::Frac(Box::new(numerator), Box::new(std::mem::replace(value, Node::Group(Vec::new())))); + } + + /// Restore the outer parser after the root-index subparse and queue the radicand argument. + fn step_sqrt_index(&mut self, work: &mut Vec>, value: &mut Node, outer: Parser<'a>, depth: usize) { + *self = outer; + let index = Some(Box::new(std::mem::replace(value, Node::Group(Vec::new())))); + work.push(ParseWork::SqrtArgument { index }); + work.push(ParseWork::Arg { depth }); + } + + /// Build `Sqrt`, retaining the optional root index. + fn step_sqrt_argument(value: &mut Node, index: Option>) { + *value = Node::Sqrt(index, Box::new(std::mem::replace(value, Node::Group(Vec::new())))); + } + + /// Wrap the completed body in `Fenced` with the opening and resolved closing delimiter. + fn step_fence(&mut self, value: &mut Node, open: String) { + let close = self.delimiter(); + *value = Node::Fenced(open, Box::new(std::mem::replace(value, Node::Group(Vec::new()))), close); + } + + /// Start a fence-body sequence that stops at a real `\right`, or an empty group at the nesting cap. + fn step_until_right(work: &mut Vec>, value: &mut Node, depth: usize) { + if depth >= MAX_NESTING { + *value = Node::Group(Vec::new()); + } else { + work.push(ParseWork::Sequence { kind: SequenceKind::Fence, depth, out: Vec::new() }); + } + } + + /// Queue one atom and its one-item sequence collector. + fn step_one(work: &mut Vec>, depth: usize) { + work.push(ParseWork::OneAtom { depth }); + work.push(ParseWork::Atom { depth }); + } + + /// Wrap the completed atom in a `Group` and continue the `One` sequence. + fn step_one_atom(work: &mut Vec>, value: &mut Node, depth: usize) { + let out = vec![std::mem::replace(value, Node::Group(Vec::new()))]; + work.push(ParseWork::Sequence { kind: SequenceKind::One, depth, out }); + } + + /// Parse a number, identifier, operator, punctuation mark or symbol with its spacing class, dispatching braces and commands. + fn step_atom(&mut self, work: &mut Vec>, value: &mut Node, depth: usize) { + self.skip_ws(); + let Some(c) = self.peek() else { + *value = Node::Group(Vec::new()); + return; + }; + self.i += 1; + match c { + '{' => work.push(ParseWork::List { end: Some('}'), depth }), + '\\' => self.step_command(work, value, depth), c if c.is_ascii_digit() || c == '.' => { let mut s = c.to_string(); while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') { s.push(d); self.i += 1; } - Node::Sym(s, Class::Number) + *value = Node::Sym(s, Class::Number); } - c if c.is_alphabetic() => Node::Sym(c.to_string(), Class::Ident), - '+' => Node::Sym("+".into(), Class::Bin), - '-' => Node::Sym("−".into(), Class::Bin), - '*' => Node::Sym("∗".into(), Class::Bin), - '<'..='>' => Node::Sym(c.to_string(), Class::Rel), - ',' | ';' => Node::Sym(c.to_string(), Class::Punct), - '\'' => Node::Sym("′".into(), Class::Plain), - c => Node::Sym(c.to_string(), Class::Plain), - }) - } - fn list(&mut self, end: Option) -> Vec { - if self.depth >= MAX_NESTING { - return Vec::new(); + c if c.is_alphabetic() => *value = Node::Sym(c.to_string(), Class::Ident), + '+' => *value = Node::Sym("+".into(), Class::Bin), + '-' => *value = Node::Sym("−".into(), Class::Bin), + '*' => *value = Node::Sym("∗".into(), Class::Bin), + '<'..='>' => *value = Node::Sym(c.to_string(), Class::Rel), + ',' | ';' => *value = Node::Sym(c.to_string(), Class::Punct), + '\'' => *value = Node::Sym("′".into(), Class::Plain), + c => *value = Node::Sym(c.to_string(), Class::Plain), } - self.depth += 1; - let mut out: Vec = Vec::new(); - loop { - self.skip_ws(); - match self.peek() { - None => break, - Some(c) if Some(c) == end => { - self.i += 1; + } + + /// Dispatch fractions, roots, fences, raw-text commands and named symbols/operators. + fn step_command(&mut self, work: &mut Vec>, value: &mut Node, depth: usize) { + let name = self.command(); + match name.as_str() { + "frac" | "dfrac" | "tfrac" => { + work.push(ParseWork::FractionNumerator { depth }); + work.push(ParseWork::Arg { depth }); + } + "sqrt" => self.push_sqrt(work, depth), + "left" => { + let open = self.delimiter(); + work.push(ParseWork::Fence { open }); + work.push(ParseWork::UntilRight { depth }); + } + "text" | "mathrm" | "textrm" | "operatorname" => *value = Node::Text(self.raw_group()), + n => *value = command_atom(n), + } + } + + /// Parse an optional bracketed root index with the existing first-`]` grammar, otherwise queue a braced or atomic radicand. + fn push_sqrt(&mut self, work: &mut Vec>, depth: usize) { + self.skip_ws(); + if self.peek() == Some('[') { + self.i += 1; + let start = self.i; + let mut end = start; + // Keep the existing first-']' index grammar, including EOF. + while let Some(c) = self.peek() { + self.i += 1; + if c == ']' { break; } - Some('^') | Some('_') => { - let up = self.peek() == Some('^'); - self.i += 1; - let a = self.arg(); - let base = out.pop().unwrap_or(Node::Group(vec![])); - let node = match base { - Node::Scripts { base, sup, sub } => { - if up { - Node::Scripts { base, sup: Some(Box::new(a)), sub } - } else { - Node::Scripts { base, sup, sub: Some(Box::new(a)) } - } - } - b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, - }; - out.push(node); - } - Some(_) => { - if let Some(a) = self.atom() { - out.push(a); - } - } + end = self.i; } + let inner = self.s.get(start..end).unwrap_or_default(); + let outer = std::mem::replace(self, Parser { s: inner, i: 0 }); + work.push(ParseWork::SqrtIndex { outer, depth }); + work.push(ParseWork::List { end: None, depth: depth + 1 }); + } else { + work.push(ParseWork::SqrtArgument { index: None }); + work.push(ParseWork::Arg { depth }); } - self.depth -= 1; - out } - fn list_until_right(&mut self) -> Vec { - if self.depth >= MAX_NESTING { - return Vec::new(); - } - let mut out = Vec::new(); - loop { - self.skip_ws(); - if self.peek().is_none() { - break; - } - if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) && !self.s.get(self.i + 6).is_some_and(char::is_ascii_alphabetic) { - self.i += 6; - break; - } - let mut p = Parser { s: self.s, i: self.i, depth: self.depth + 1 }; - let one = p.list_one(); - self.i = p.i; - out.extend(one); - } - out - } - /// One atom plus any scripts on it. - fn list_one(&mut self) -> Vec { - let mut out = Vec::new(); - if let Some(a) = self.atom() { - out.push(a); - } - loop { - self.skip_ws(); - match self.peek() { - Some('^') | Some('_') => { - let up = self.peek() == Some('^'); - self.i += 1; - let a = self.arg(); - let base = out.pop().unwrap_or(Node::Group(vec![])); - out.push(match base { - Node::Scripts { base, sub, .. } if up => Node::Scripts { base, sup: Some(Box::new(a)), sub }, - Node::Scripts { base, sup, .. } => Node::Scripts { base, sup, sub: Some(Box::new(a)) }, - b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, - }); - } - _ => break, - } - } - out +} + +/// Map function names, spacing commands, escaped punctuation, big operators and symbols; an unknown command becomes literal `\name` text. +fn command_atom(name: &str) -> Node { + match name { + "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => Node::Text(name.to_owned()), + "," => Node::Space(3.0 / 18.0), + ":" | ">" => Node::Space(4.0 / 18.0), + ";" => Node::Space(5.0 / 18.0), + "quad" => Node::Space(1.0), + "qquad" => Node::Space(2.0), + "!" => Node::Space(-3.0 / 18.0), + " " => Node::Space(0.25), + "{" => Node::Sym("{".into(), Class::Punct), + "}" => Node::Sym("}".into(), Class::Punct), + "%" | "$" | "#" | "&" | "_" => Node::Sym(name.to_owned(), Class::Plain), + n => match (big_op(n), symbol(n)) { + (Some(op), _) => Node::Big(op.into()), + (None, Some((s, cl))) => Node::Sym(s.into(), cl), + _ => Node::Text(format!("\\{n}")), + }, } } @@ -432,10 +594,10 @@ fn parse(src: &str) -> Node { parse_at(src, 0) } +/// Collect the input into chars and start parsing at the supplied depth. fn parse_at(src: &str, depth: usize) -> Node { let chars: Vec = src.chars().collect(); - let mut p = Parser { s: &chars, i: 0, depth }; - Node::Group(p.list(None)) + Parser { s: &chars, i: 0 }.parse(depth) } /// Typesetting fonts and metrics. @@ -484,174 +646,254 @@ fn hbox(parts: Vec<(MBox, f64)>) -> MBox { out } -fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { - let axis = size * 0.25; - match n { - Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), - Node::Text(s) => text_box(f, s, size, false), - Node::Space(em) => MBox { w: em * size, ..Default::default() }, - Node::Group(list) => { - let mut parts = Vec::new(); - let mut prev: Option = None; - for (k, item) in list.iter().enumerate() { - let cl = match item { - Node::Sym(_, c) => Some(*c), - Node::Scripts { base, .. } => match &**base { - Node::Sym(_, c) => Some(*c), - _ => None, - }, - _ => None, - }; - // A leading + or − (or after another operator) is a sign, not a binary operator. - let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { - Some(Class::Plain) - } else { - cl - }; - let gap = |c: Option| match c { - Some(Class::Bin) => size * 4.0 / 18.0, - Some(Class::Rel) => size * 5.0 / 18.0, - _ => 0.0, - }; - let mut before = gap(cl).max(gap(prev)); - if k == 0 { - before = 0.0; - } - if prev == Some(Class::Punct) { - before = before.max(size * 3.0 / 18.0); - } - parts.push((layout(f, item, size), before)); - prev = cl; - } - hbox(parts) - } - Node::Frac(a, b) => { - let s2 = size * 0.85; - let (na, nb) = (layout(f, a, s2), layout(f, b, s2)); - let w = na.w.max(nb.w) + size * 0.2; - let t = (size * 0.06).max(0.4); - let gap = size * 0.15; - let num_base = -(axis + t / 2.0 + gap + na.desc); - let den_base = -axis + t / 2.0 + gap + nb.asc; - let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; - out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); - let (wa, wb) = (na.w, nb.w); - out.els.extend(na.shifted((w - wa) / 2.0, num_base)); - out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); - out - } - Node::Sqrt(idx, a) => { - let inner = layout(f, a, size); - let t = (size * 0.05).max(0.4); - let gap = size * 0.12; - let top = inner.asc + gap + t; - let bottom = inner.desc; - let hook = size * 0.55; - let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; - // The radical: a short rise, the long stroke down, then up to the bar. - out.els.push(Elem::Line { - pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], - w: t, - }); - let iw = inner.w; - let _ = iw; - out.els.extend(inner.shifted(hook, 0.0)); - if let Some(i) = idx { - let ib = layout(f, i, size * 0.55); - let (ia, iw2) = (ib.asc, ib.w); - out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); - out.asc = out.asc.max(axis + size * 0.1 + ia); - } - out - } - Node::Big(op) => { - let mut b = text_box(f, op, size * 1.5, false); - b.asc = size * 1.05; - b.desc = size * 0.35; - for e in &mut b.els { - if let Elem::Text { y, .. } = e { - // Centre the glyph on the math axis. - *y = size * 0.32; - } - } - b - } - Node::Scripts { base, sup, sub } => { - let big = matches!(&**base, Node::Big(_)); - let b = layout(f, base, size); - let s2 = size * 0.7; - let (bw, basc, bdesc) = (b.w, b.asc, b.desc); - let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; - if big { - // Limits above and below. - let mut w = bw; - if let Some(sp) = sup { - let sb = layout(f, sp, s2); - let y = -basc - size * 0.1 - sb.desc; - w = w.max(sb.w); - let (sw, sa) = (sb.w, sb.asc); - out.els.extend(sb.shifted((bw - sw) / 2.0, y)); - out.asc = out.asc.max(-y + sa); - } - if let Some(sbn) = sub { - let sb = layout(f, sbn, s2); - let y = bdesc + size * 0.1 + sb.asc; - w = w.max(sb.w); - let (sw, sd) = (sb.w, sb.desc); - out.els.extend(sb.shifted((bw - sw) / 2.0, y)); - out.desc = out.desc.max(y + sd); - } - // Centre the operator over the widest limit. - let dx = (w - bw) / 2.0; - let els = std::mem::take(&mut out.els); - out = MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; - return out; - } - let mut w = bw; - if let Some(sp) = sup { - let sb = layout(f, sp, s2); - let y = -(size * 0.42).max(basc - sb.asc * 0.6); - w = w.max(bw + sb.w + size * 0.05); - let sa = sb.asc; - out.els.extend(sb.shifted(bw + size * 0.05, y)); - out.asc = out.asc.max(-y + sa); - } - if let Some(sbn) = sub { - let sb = layout(f, sbn, s2); - let y = (size * 0.2).max(bdesc * 0.8); - w = w.max(bw + sb.w + size * 0.05); - let sd = sb.desc; - out.els.extend(sb.shifted(bw + size * 0.05, y)); - out.desc = out.desc.max(y + sd); - } - out.w = w; - out - } - Node::Fenced(open, body, close) => { - let inner = layout(f, body, size); - let h = (inner.asc + inner.desc).max(size); - let k = h / size; - let ds = size * k.max(1.0); - let mk = |s: &str| -> MBox { - if s.is_empty() { - return MBox::default(); - } - let mut b = text_box(f, s, ds, false); - // Scaled delimiters centred on the content. - let mid = (inner.desc - inner.asc) / 2.0; - for e in &mut b.els { - if let Elem::Text { y, .. } = e { - *y = mid + ds * 0.25; - } - } - b.asc = inner.asc.max(size * 0.72); - b.desc = inner.desc.max(size * 0.22); - b - }; - let (l, r) = (mk(open), mk(close)); - hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) +struct GroupLayout<'a> { + items: std::iter::Enumerate>, + parts: Vec<(MBox, f64)>, + prev: Option, + size: f64, +} + +struct ScriptLayout { + out: MBox, + bw: f64, + basc: f64, + bdesc: f64, + big: bool, + size: f64, +} + +impl ScriptLayout { + /// Seed script layout with the base box width and metrics, the big-operator flag and the size. + fn new(base: MBox, big: bool, size: f64) -> Self { + Self { bw: base.w, basc: base.asc, bdesc: base.desc, out: base, big, size } + } + /// Place a superscript, subscript or big-operator limit and expand the aggregate width, ascent and descent. + fn add(&mut self, script: MBox, up: bool) { + let Self { out, bw, basc, bdesc, big, size } = self; + let y = if *big { + if up { -*basc - *size * 0.1 - script.desc } else { *bdesc + *size * 0.1 + script.asc } + } else if up { + -(*size * 0.42).max(*basc - script.asc * 0.6) + } else { + (*size * 0.2).max(*bdesc * 0.8) + }; + out.w = out.w.max(if *big { script.w } else { *bw + script.w + *size * 0.05 }); + let (sa, sd) = (script.asc, script.desc); + let x = if *big { (*bw - script.w) / 2.0 } else { *bw + *size * 0.05 }; + out.els.extend(script.shifted(x, y)); + if up { + out.asc = out.asc.max(-y + sa); + } else { + out.desc = out.desc.max(y + sd); } } + /// For big operators, recentre the operator over the widest limit. + fn finish(mut self) -> MBox { + if self.big { + let dx = (self.out.w - self.bw) / 2.0; + let els = std::mem::take(&mut self.out.els); + self.out.els = MBox { els, ..Default::default() }.shifted(dx, 0.0); + } + self.out + } +} + +enum LayoutWork<'a> { + Visit(&'a Node, f64), + GroupNext(GroupLayout<'a>), + GroupAppend(GroupLayout<'a>, f64), + FractionNumerator(&'a Node, f64), + FractionDenominator(MBox, f64), + SqrtBody(Option<&'a Node>, f64), + SqrtIndex(MBox, f64), + ScriptBase { sup: Option<&'a Node>, sub: Option<&'a Node>, big: bool, size: f64 }, + ScriptSup { state: ScriptLayout, sub: Option<&'a Node> }, + ScriptSub(ScriptLayout), + Fence(&'a str, &'a str, f64), +} + +/// Postorder evaluation moves each child's box into its parent before visiting the next child. +/// No ancestor retains a second flattened copy, and native stack use is independent of nesting. +fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { + let mut work = vec![LayoutWork::Visit(n, size)]; + let mut value = MBox::default(); + while let Some(next) = work.pop() { + match next { + LayoutWork::Visit(node, size) => match node { + Node::Sym(s, cl) => value = text_box(f, s, size, *cl == Class::Ident), + Node::Text(s) => value = text_box(f, s, size, false), + Node::Space(em) => value = MBox { w: em * size, ..Default::default() }, + Node::Group(list) => { + work.push(LayoutWork::GroupNext(GroupLayout { items: list.iter().enumerate(), parts: Vec::new(), prev: None, size })) + } + Node::Frac(a, b) => { + work.push(LayoutWork::FractionNumerator(b, size)); + work.push(LayoutWork::Visit(a, size * 0.85)); + } + Node::Sqrt(index, body) => { + work.push(LayoutWork::SqrtBody(index.as_deref(), size)); + work.push(LayoutWork::Visit(body, size)); + } + Node::Scripts { base, sup, sub } => { + work.push(LayoutWork::ScriptBase { sup: sup.as_deref(), sub: sub.as_deref(), big: matches!(&**base, Node::Big(_)), size }); + work.push(LayoutWork::Visit(base, size)); + } + Node::Big(op) => { + let mut b = text_box(f, op, size * 1.5, false); + b.asc = size * 1.05; + b.desc = size * 0.35; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = size * 0.32; + } + } + value = b; + } + Node::Fenced(open, body, close) => { + work.push(LayoutWork::Fence(open, close, size)); + work.push(LayoutWork::Visit(body, size)); + } + }, + LayoutWork::GroupNext(mut group) => { + if let Some((k, item)) = group.items.next() { + let cl = match item { + Node::Sym(_, c) => Some(*c), + Node::Scripts { base, .. } => match &**base { + Node::Sym(_, c) => Some(*c), + _ => None, + }, + _ => None, + }; + let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(group.prev, Some(Class::Bin | Class::Rel | Class::Punct))) { + Some(Class::Plain) + } else { + cl + }; + let gap = |c: Option| match c { + Some(Class::Bin) => group.size * 4.0 / 18.0, + Some(Class::Rel) => group.size * 5.0 / 18.0, + _ => 0.0, + }; + let mut before = gap(cl).max(gap(group.prev)); + if k == 0 { + before = 0.0; + } + if group.prev == Some(Class::Punct) { + before = before.max(group.size * 3.0 / 18.0); + } + group.prev = cl; + let size = group.size; + work.push(LayoutWork::GroupAppend(group, before)); + work.push(LayoutWork::Visit(item, size)); + } else { + value = hbox(group.parts); + } + } + LayoutWork::GroupAppend(mut group, before) => { + group.parts.push((std::mem::take(&mut value), before)); + work.push(LayoutWork::GroupNext(group)); + } + LayoutWork::FractionNumerator(denominator, size) => { + work.push(LayoutWork::FractionDenominator(std::mem::take(&mut value), size)); + work.push(LayoutWork::Visit(denominator, size * 0.85)); + } + LayoutWork::FractionDenominator(na, size) => { + let nb = std::mem::take(&mut value); + let axis = size * 0.25; + let w = na.w.max(nb.w) + size * 0.2; + let t = (size * 0.06).max(0.4); + let gap = size * 0.15; + let num_base = -(axis + t / 2.0 + gap + na.desc); + let den_base = -axis + t / 2.0 + gap + nb.asc; + let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; + out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); + let (wa, wb) = (na.w, nb.w); + out.els.extend(na.shifted((w - wa) / 2.0, num_base)); + out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); + value = out; + } + LayoutWork::SqrtBody(index, size) => { + let inner = std::mem::take(&mut value); + let axis = size * 0.25; + let t = (size * 0.05).max(0.4); + let gap = size * 0.12; + let top = inner.asc + gap + t; + let bottom = inner.desc; + let hook = size * 0.55; + let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; + out.els.push(Elem::Line { + pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], + w: t, + }); + out.els.extend(inner.shifted(hook, 0.0)); + if let Some(index) = index { + work.push(LayoutWork::SqrtIndex(out, size)); + work.push(LayoutWork::Visit(index, size * 0.55)); + } else { + value = out; + } + } + LayoutWork::SqrtIndex(mut out, size) => { + let ib = std::mem::take(&mut value); + let (ia, iw) = (ib.asc, ib.w); + let axis = size * 0.25; + let hook = size * 0.55; + out.els.extend(ib.shifted((hook * 0.3 - iw).max(0.0) - 0.0, -axis - size * 0.1)); + out.asc = out.asc.max(axis + size * 0.1 + ia); + value = out; + } + LayoutWork::ScriptBase { sup, sub, big, size } => { + let state = ScriptLayout::new(std::mem::take(&mut value), big, size); + if let Some(sup) = sup { + work.push(LayoutWork::ScriptSup { state, sub }); + work.push(LayoutWork::Visit(sup, size * 0.7)); + } else if let Some(sub) = sub { + work.push(LayoutWork::ScriptSub(state)); + work.push(LayoutWork::Visit(sub, size * 0.7)); + } else { + value = state.finish(); + } + } + LayoutWork::ScriptSup { mut state, sub } => { + state.add(std::mem::take(&mut value), true); + if let Some(sub) = sub { + let size = state.size; + work.push(LayoutWork::ScriptSub(state)); + work.push(LayoutWork::Visit(sub, size * 0.7)); + } else { + value = state.finish(); + } + } + LayoutWork::ScriptSub(mut state) => { + state.add(std::mem::take(&mut value), false); + value = state.finish(); + } + LayoutWork::Fence(open, close, size) => { + let inner = std::mem::take(&mut value); + let h = (inner.asc + inner.desc).max(size); + let k = h / size; + let ds = size * k.max(1.0); + let mk = |s: &str| -> MBox { + if s.is_empty() { + return MBox::default(); + } + let mut b = text_box(f, s, ds, false); + let mid = (inner.desc - inner.asc) / 2.0; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = mid + ds * 0.25; + } + } + b.asc = inner.asc.max(size * 0.72); + b.desc = inner.desc.max(size * 0.22); + b + }; + let (l, r) = (mk(open), mk(close)); + value = hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]); + } + } + } + value } /// Typeset `latex` at `size` points: SVG bytes and the size in points. @@ -703,73 +945,4 @@ pub fn to_svg(latex: &str, size: f64) -> (Vec, (f64, f64)) { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_the_common_constructs() { - let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); - let Node::Group(v) = n else { panic!() }; - assert!(matches!(&v[0], Node::Frac(_, _))); - let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); - let Node::Group(v) = n else { panic!() }; - assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); - assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); - assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); - let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); - let Node::Group(v) = n else { panic!() }; - assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); - assert!(v.contains(&Node::Text("if ".into()))); - } - - /// A command that merely starts with "right" (`\rightarrow`) must not close a `\left` fence. - #[test] - fn right_prefixed_commands_do_not_close_a_fence() { - let n = parse(r"\left( x \rightarrow y \right)"); - let Node::Group(v) = n else { panic!() }; - assert_eq!(v.len(), 1, "{v:?}"); - let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") }; - assert!(o == "(" && c == ")", "{v:?}"); - let Node::Group(b) = &**body else { panic!("{v:?}") }; - assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]); - // An unknown `\right…` command stays inside the fence too. - let Node::Group(v) = parse(r"\left[ \rightfoo \right]") else { panic!() }; - assert_eq!(v.len(), 1, "{v:?}"); - assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}"); - // Real closers still close it. - for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] { - let Node::Group(v) = parse(src) else { panic!() }; - assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}"); - } - } - - #[test] - fn layout_sizes_and_svg() { - let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0); - let s = String::from_utf8(svg).unwrap(); - assert!(s.starts_with("E<") && s.contains(">2<")); - assert!(w > 30.0 && h > 10.0, "{w} {h}"); - // A fraction is taller than its numerator alone; a big operator with limits taller still. - let (_, (_, h1)) = to_svg("x", 12.0); - let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0); - let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0); - assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}"); - // The SVG parses and rasterises. - let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); - assert!(designcraft_images::svg_tree(&svg).is_some()); - } - - /// Hostile input must not overflow the stack: nesting past the cap stops descending instead - /// of recursing once per group (100k levels used to abort the process). - #[test] - fn an_over_deep_expression_is_bounded() { - for latex in [ - format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), - format!("{}x", r"\frac".repeat(100_000)), - format!("{}x", r"\left(".repeat(100_000)), - ] { - let (svg, _) = to_svg(&latex, 12.0); - assert!(!svg.is_empty()); - } - } -} +mod tests; diff --git a/crates/engine/src/math/tests.rs b/crates/engine/src/math/tests.rs new file mode 100644 index 000000000..9d1e75884 --- /dev/null +++ b/crates/engine/src/math/tests.rs @@ -0,0 +1,476 @@ +use super::*; + +// Frozen pre-repair arithmetic; called only for shallow fixtures, never deep input. +/// Frozen recursive layout oracle for shallow test fixtures only, never deep input. +fn recursive_layout_reference(f: &Fonts, n: &Node, size: f64) -> MBox { + match n { + Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), + Node::Text(s) => text_box(f, s, size, false), + Node::Space(em) => MBox { w: em * size, ..Default::default() }, + Node::Group(list) => recursive_group_reference(f, list, size), + Node::Frac(a, b) => recursive_fraction_reference(f, a, b, size), + Node::Sqrt(idx, a) => recursive_sqrt_reference(f, idx, a, size), + Node::Big(op) => recursive_big_reference(f, op, size), + Node::Scripts { base, sup, sub } => recursive_scripts_reference(f, base, sup, sub, size), + Node::Fenced(open, body, close) => recursive_fenced_reference(f, open, body, close, size), + } +} + +/// Oracle for operator spacing, leading-sign treatment and punctuation gaps. +fn recursive_group_reference(f: &Fonts, list: &[Node], size: f64) -> MBox { + let mut parts = Vec::new(); + let mut prev: Option = None; + for (k, item) in list.iter().enumerate() { + let cl = match item { + Node::Sym(_, c) => Some(*c), + Node::Scripts { base, .. } => match &**base { + Node::Sym(_, c) => Some(*c), + _ => None, + }, + _ => None, + }; + // A leading + or − (or after another operator) is a sign, not a binary operator. + let cl = + if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { Some(Class::Plain) } else { cl }; + let gap = |c: Option| match c { + Some(Class::Bin) => size * 4.0 / 18.0, + Some(Class::Rel) => size * 5.0 / 18.0, + _ => 0.0, + }; + let mut before = gap(cl).max(gap(prev)); + if k == 0 { + before = 0.0; + } + if prev == Some(Class::Punct) { + before = before.max(size * 3.0 / 18.0); + } + parts.push((recursive_layout_reference(f, item, size), before)); + prev = cl; + } + hbox(parts) +} + +/// Oracle for fraction rule, child scale, math axis, gaps and bounds. +fn recursive_fraction_reference(f: &Fonts, a: &Node, b: &Node, size: f64) -> MBox { + let axis = size * 0.25; + let s2 = size * 0.85; + let (na, nb) = (recursive_layout_reference(f, a, s2), recursive_layout_reference(f, b, s2)); + let w = na.w.max(nb.w) + size * 0.2; + let t = (size * 0.06).max(0.4); + let gap = size * 0.15; + let num_base = -(axis + t / 2.0 + gap + na.desc); + let den_base = -axis + t / 2.0 + gap + nb.asc; + let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; + out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); + let (wa, wb) = (na.w, nb.w); + out.els.extend(na.shifted((w - wa) / 2.0, num_base)); + out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); + out +} + +/// Oracle for radical geometry, body offset and optional root index. +fn recursive_sqrt_reference(f: &Fonts, idx: &Option>, a: &Node, size: f64) -> MBox { + let axis = size * 0.25; + let inner = recursive_layout_reference(f, a, size); + let t = (size * 0.05).max(0.4); + let gap = size * 0.12; + let top = inner.asc + gap + t; + let bottom = inner.desc; + let hook = size * 0.55; + let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; + // The radical: a short rise, the long stroke down, then up to the bar. + out.els.push(Elem::Line { + pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], + w: t, + }); + let iw = inner.w; + let _ = iw; + out.els.extend(inner.shifted(hook, 0.0)); + if let Some(i) = idx { + let ib = recursive_layout_reference(f, i, size * 0.55); + let (ia, iw2) = (ib.asc, ib.w); + out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); + out.asc = out.asc.max(axis + size * 0.1 + ia); + } + out +} + +/// Oracle for enlarged, math-axis-centred big operators. +fn recursive_big_reference(f: &Fonts, op: &str, size: f64) -> MBox { + let mut b = text_box(f, op, size * 1.5, false); + b.asc = size * 1.05; + b.desc = size * 0.35; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + // Centre the glyph on the math axis. + *y = size * 0.32; + } + } + b +} + +/// Oracle for ordinary sup/sub placement and big-operator limits above and below. +fn recursive_scripts_reference(f: &Fonts, base: &Node, sup: &Option>, sub: &Option>, size: f64) -> MBox { + let big = matches!(base, Node::Big(_)); + let b = recursive_layout_reference(f, base, size); + let s2 = size * 0.7; + let (bw, basc, bdesc) = (b.w, b.asc, b.desc); + let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; + if big { + // Limits above and below. + let mut w = bw; + if let Some(sp) = sup { + let sb = recursive_layout_reference(f, sp, s2); + let y = -basc - size * 0.1 - sb.desc; + w = w.max(sb.w); + let (sw, sa) = (sb.w, sb.asc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = recursive_layout_reference(f, sbn, s2); + let y = bdesc + size * 0.1 + sb.asc; + w = w.max(sb.w); + let (sw, sd) = (sb.w, sb.desc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.desc = out.desc.max(y + sd); + } + // Centre the operator over the widest limit. + let dx = (w - bw) / 2.0; + let els = std::mem::take(&mut out.els); + return MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; + } + let mut w = bw; + if let Some(sp) = sup { + let sb = recursive_layout_reference(f, sp, s2); + let y = -(size * 0.42).max(basc - sb.asc * 0.6); + w = w.max(bw + sb.w + size * 0.05); + let sa = sb.asc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = recursive_layout_reference(f, sbn, s2); + let y = (size * 0.2).max(bdesc * 0.8); + w = w.max(bw + sb.w + size * 0.05); + let sd = sb.desc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.desc = out.desc.max(y + sd); + } + out.w = w; + out +} + +/// Oracle for content-scaled, centred delimiters. +fn recursive_fenced_reference(f: &Fonts, open: &str, body: &Node, close: &str, size: f64) -> MBox { + let inner = recursive_layout_reference(f, body, size); + let h = (inner.asc + inner.desc).max(size); + let k = h / size; + let ds = size * k.max(1.0); + let mk = |s: &str| -> MBox { + if s.is_empty() { + return MBox::default(); + } + let mut b = text_box(f, s, ds, false); + // Scaled delimiters centred on the content. + let mid = (inner.desc - inner.asc) / 2.0; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = mid + ds * 0.25; + } + } + b.asc = inner.asc.max(size * 0.72); + b.desc = inner.desc.max(size * 0.22); + b + }; + let (l, r) = (mk(open), mk(close)); + hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) +} + +/// Asserts fraction/nested-root, big scripts, relation, symbol, fence and raw `\text` parse shapes. +#[test] +fn parses_the_common_constructs() { + let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); + let Node::Group(v) = &n else { panic!() }; + assert!(matches!(&v[0], Node::Frac(_, _))); + let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); + let Node::Group(v) = &n else { panic!() }; + assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); + assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); + assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); + let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); + let Node::Group(v) = &n else { panic!() }; + assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); + assert!(v.contains(&Node::Text("if ".into()))); +} + +/// A command that merely starts with "right" (`\rightarrow`) must not close a `\left` fence. +#[test] +fn right_prefixed_commands_do_not_close_a_fence() { + let n = parse(r"\left( x \rightarrow y \right)"); + let Node::Group(v) = &n else { panic!() }; + assert_eq!(v.len(), 1, "{v:?}"); + let Node::Fenced(o, body, c) = &v[0] else { panic!("{v:?}") }; + assert!(o == "(" && c == ")", "{v:?}"); + let Node::Group(b) = &**body else { panic!("{v:?}") }; + assert_eq!(*b, vec![Node::Sym("x".into(), Class::Ident), Node::Sym("→".into(), Class::Rel), Node::Sym("y".into(), Class::Ident)]); + // An unknown `\right…` command stays inside the fence too. + let Node::Group(v) = &parse(r"\left[ \rightfoo \right]") else { panic!() }; + assert_eq!(v.len(), 1, "{v:?}"); + assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "[" && c == "]"), "{v:?}"); + // Real closers still close it. + for (src, close) in [(r"\left( x \right)", ")"), (r"\left( x \right.", ""), (r"\left( x \right)y", ")")] { + let Node::Group(v) = &parse(src) else { panic!() }; + assert!(matches!(&v[0], Node::Fenced(_, _, c) if c == close), "{src}: {v:?}"); + } +} + +#[test] +fn layout_sizes_and_svg() { + let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0); + let s = String::from_utf8(svg).unwrap(); + assert!(s.starts_with("E<") && s.contains(">2<")); + assert!(w > 30.0 && h > 10.0, "{w} {h}"); + // A fraction is taller than its numerator alone; a big operator with limits taller still. + let (_, (_, h1)) = to_svg("x", 12.0); + let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0); + let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0); + assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}"); + // The SVG parses and rasterises. + let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); + assert!(designcraft_images::svg_tree(&svg).is_some()); +} + +/// Hostile input must not overflow the stack: nesting past the cap stops descending instead +/// of recursing once per group (100k levels used to abort the process). +#[test] +fn an_over_deep_expression_is_bounded() { + for latex in [ + format!("{}x{}", "{".repeat(100_000), "}".repeat(100_000)), + format!("{}x", r"\frac".repeat(100_000)), + format!("{}x", r"\left(".repeat(100_000)), + format!("{}z{}", r"\sqrt{".repeat(1_024), "}".repeat(1_024)), + format!("{}z{}", "x^{".repeat(1_024), "}".repeat(1_024)), + ] { + let (svg, (w, h)) = to_svg(&latex, 12.0); + assert!(!svg.is_empty()); + assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); + } +} +/// Count actual tree shape without recursively comparing or formatting a deep AST. +fn shape(root: &Node) -> ([usize; 6], usize) { + let mut counts = [0; 6]; // groups, fractions, roots, fences, scripts, sentinel z + let mut deepest = 0; + let mut work = vec![(root, 1)]; + while let Some((node, depth)) = work.pop() { + deepest = deepest.max(depth); + match node { + Node::Group(nodes) => { + counts[0] += 1; + work.extend(nodes.iter().map(|node| (node, depth + 1))); + } + Node::Frac(a, b) => { + counts[1] += 1; + work.extend([(a.as_ref(), depth + 1), (b.as_ref(), depth + 1)]); + } + Node::Sqrt(index, body) => { + counts[2] += 1; + work.push((body, depth + 1)); + if let Some(index) = index { + work.push((index, depth + 1)); + } + } + Node::Fenced(_, body, _) => { + counts[3] += 1; + work.push((body, depth + 1)); + } + Node::Scripts { base, sup, sub } => { + counts[4] += 1; + work.push((base, depth + 1)); + if let Some(sup) = sup { + work.push((sup, depth + 1)); + } + if let Some(sub) = sub { + work.push((sub, depth + 1)); + } + } + Node::Sym(s, _) if s == "z" => counts[5] += 1, + _ => {} + } + } + (counts, deepest) +} + +/// With `MAX_NESTING == 256`, all supported 255-level constructs stay nested and still render their sentinel. +#[test] +fn the_full_256_budget_keeps_deep_valid_formulas_intact() { + assert_eq!(MAX_NESTING, 256, "preserve the existing expression capability"); + let groups = MAX_NESTING - 1; + let arguments = (MAX_NESTING - 1) / 2; + let index_groups = MAX_NESTING - 3; + let cases = [ + (format!("{}z{}", "{".repeat(groups), "}".repeat(groups)), [groups + 1, 0, 0, 0, 0, 1], groups + 2), + (format!("{}z{}", r"\left(".repeat(groups), r"\right)".repeat(groups)), [groups + 1, 0, 0, groups, 0, 1], groups * 2 + 2), + (format!("{}z{}", r"\frac{".repeat(arguments), "}{y}".repeat(arguments)), [arguments * 2 + 1, arguments, 0, 0, 0, 1], arguments * 2 + 2), + (format!("{}z{}", r"\sqrt{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, arguments, 0, 0, 1], arguments * 2 + 2), + (format!("{}z{}", "x^{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), + (format!("{}z{}", "x_{".repeat(arguments), "}".repeat(arguments)), [arguments + 1, 0, 0, 0, arguments, 1], arguments * 2 + 2), + (format!(r"\sqrt[{}z{}]{{y}}", "{".repeat(index_groups), "}".repeat(index_groups)), [index_groups + 3, 0, 1, 0, 0, 1], index_groups + 4), + ]; + for (latex, counts, depth) in cases { + assert_eq!(shape(&parse(&latex)), (counts, depth), "all constructs must remain nested, not flatten into siblings"); + let (svg, (w, h)) = to_svg(&latex, 12.0); + assert!(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0, "{w} {h}"); + assert!(String::from_utf8(svg).unwrap().contains(">z<"), "the innermost content must render"); + } +} + +/// A capped list consumes nothing; capped root-index/body cursor behavior and the permissive first-`]` grammar remain unchanged. +#[test] +fn cap_and_index_returns_preserve_their_exact_cursor_semantics() { + let chars: Vec<_> = "x".chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!(p.parse(MAX_NESTING - 1), Node::Group(vec![Node::Sym("x".into(), Class::Ident)])); + assert_eq!(p.i, 1); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!(p.parse(MAX_NESTING), Node::Group(vec![])); + assert_eq!(p.i, 0, "a capped List does not consume its input"); + // Both the index subparse and radicand still have their entire old depth allowance. + let source = r"\sqrt[x]{y}z"; + assert_eq!( + parse_at(source, MAX_NESTING - 4), + Node::Group(vec![ + Node::Sqrt( + Some(Box::new(Node::Group(vec![Node::Sym("x".into(), Class::Ident)]))), + Box::new(Node::Group(vec![Node::Sym("y".into(), Class::Ident)])) + ), + Node::Sym("z".into(), Class::Ident), + ]) + ); + // At this starting depth the index and braced body are capped. The outer cursor is + // restored after ']', then the old non-consuming body cap leaves y/}/z as siblings. + let chars: Vec<_> = source.chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + assert_eq!( + p.parse(MAX_NESTING - 2), + Node::Group(vec![ + Node::Sqrt(Some(Box::new(Node::Group(vec![]))), Box::new(Node::Group(vec![]))), + Node::Sym("y".into(), Class::Ident), + Node::Sym("}".into(), Class::Plain), + Node::Sym("z".into(), Class::Ident), + ]) + ); + assert_eq!(p.i, chars.len()); + // Do not change the permissive first-']' index grammar into balanced bracket parsing. + assert_eq!( + parse(r"\sqrt[x[y]z]q"), + Node::Group(vec![ + Node::Sqrt( + Some(Box::new(Node::Group(vec![ + Node::Sym("x".into(), Class::Ident), + Node::Sym("[".into(), Class::Plain), + Node::Sym("y".into(), Class::Ident) + ]))), + Box::new(Node::Sym("z".into(), Class::Ident)) + ), + Node::Sym("]".into(), Class::Plain), + Node::Sym("q".into(), Class::Ident), + ]) + ); +} + +/// Repeated scripts replace slots; a fence `^` stays literal; incomplete fractions, escaped braces and unknown commands retain the prior grammar. +#[test] +fn heap_continuations_preserve_small_grammar_and_script_precedence() { + let sym = |s: &str| Node::Sym(s.into(), Class::Ident); + assert_eq!( + parse("x^{a}_{b}^{c}_{d}"), + Node::Group(vec![Node::Scripts { + base: Box::new(sym("x")), + sup: Some(Box::new(Node::Group(vec![sym("c")]))), + sub: Some(Box::new(Node::Group(vec![sym("d")]))), + }]) + ); + assert_eq!( + parse(r"\left( ^x \right)"), + Node::Group(vec![Node::Fenced("(".into(), Box::new(Node::Group(vec![Node::Sym("^".into(), Class::Plain), sym("x")])), ")".into())]) + ); + assert_eq!(parse(r"\frac{x"), Node::Group(vec![Node::Frac(Box::new(Node::Group(vec![sym("x")])), Box::new(Node::Group(vec![])))])); + assert_eq!( + parse(r"\text{a{b}}\{\}\_\unknown"), + Node::Group(vec![ + Node::Text("a{b}".into()), + Node::Sym("{".into(), Class::Punct), + Node::Sym("}".into(), Class::Punct), + Node::Sym("_".into(), Class::Plain), + Node::Text(r"\unknown".into()) + ]) + ); +} + +/// Iterative layout equals the frozen recursive oracle for representative formulas, including malformed inputs. +#[test] +fn heap_layout_preserves_the_existing_element_order_and_geometry() { + let db = FontDb::global(); + let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; + for latex in [ + "E = mc^2", + "x^{a}_{b}^{c}_{d}", + r"\frac{a^2+b}{\sqrt[3]{x}}", + r"\sum_{i=0}^{n} i^2", + r"\int_a^b x", + r"\left( x \rightarrow y \right) + \text{if } x", + r"\left. x \right]", + r"-x,+y;\quad z", + r"\sqrt[x[y]z]q", + r"\frac{x", + r"\left( ^x \right)", + ] { + let tree = parse(latex); + let actual = layout(&f, &tree, 12.0); + let expected = recursive_layout_reference(&f, &tree, 12.0); + assert_eq!((actual.w, actual.asc, actual.desc, actual.els), (expected.w, expected.asc, expected.desc, expected.els), "{latex}"); + } +} + +/// Build a deep five-way alternating AST for destructor stress tests. +fn mixed_chain(depth: usize) -> Node { + let mut node = Node::Sym("z".into(), Class::Ident); + for i in 0..depth { + node = match i % 5 { + 0 => Node::Group(vec![node]), + 1 => Node::Frac(Box::new(node), Box::new(Node::Text("d".into()))), + 2 => Node::Sqrt(Some(Box::new(Node::Text("3".into()))), Box::new(node)), + 3 => Node::Scripts { base: Box::new(node), sup: Some(Box::new(Node::Text("p".into()))), sub: Some(Box::new(Node::Text("q".into()))) }, + _ => Node::Fenced("(".into(), Box::new(node), ")".into()), + }; + } + node +} + +/// Drops 50k-node chains, replaces one script slot, and unwinds parser continuations while preserving the retained slot. +#[test] +fn deep_nodes_drop_when_replaced_or_owned_by_unwinding_continuations() { + // Far beyond the parser budget: this directly proves the private destructor's traversal. + drop(mixed_chain(50_000)); + for up in [false, true] { + let old = Box::new(mixed_chain(50_000)); + let other = Box::new(Node::Text("other".into())); + let (sup, sub) = if up { (Some(old), Some(other)) } else { (Some(other), Some(old)) }; + let root = attach_script(Node::Scripts { base: Box::new(Node::Big("∑".into())), sup, sub }, Node::Text("new".into()), up); + let Node::Scripts { base, sup, sub } = &root else { panic!("script wrapper") }; + assert!(matches!(&**base, Node::Big(s) if s == "∑")); + let (replaced, retained) = if up { (sup, sub) } else { (sub, sup) }; + assert!(matches!(replaced.as_deref(), Some(Node::Text(s)) if s == "new")); + assert!(matches!(retained.as_deref(), Some(Node::Text(s)) if s == "other")); + } + let result = std::panic::catch_unwind(|| { + // Actual continuation variants own completed children while awaiting later input. + let _partial = [ + ParseWork::FractionDenominator { numerator: mixed_chain(25_000) }, + ParseWork::SqrtArgument { index: Some(Box::new(mixed_chain(25_000))) }, + ]; + panic!("exercise partial-parser-state unwinding"); + }); + assert!(result.is_err(), "the intentional panic must actually unwind the owned frames"); +} diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 7ec62d073..eb6d99886 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -858,841 +858,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat } #[cfg(test)] -mod tests { - use super::*; - - fn dirty_session() -> Session { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - session.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - session - } - - #[test] - fn recovers_unsaved_documents() { - let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("file.new", &json!({})).unwrap(); - // Only the edited document is written. - s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(save(&s, &dir).unwrap(), 1); - assert_eq!(list(&dir).len(), 1); - // "Crash": a new session reopens it, unsaved, with the item. - let mut s2 = Session::new(); - let opened = open(&mut s2, &dir).unwrap(); - assert_eq!(opened.len(), 1); - let st = s2.doc().unwrap(); - assert!(st.is_dirty()); - assert_eq!(st.doc.spreads[0].items.len(), 1); - // The entry moved to the reopened document: a session that ends before its first - // recovery save (a restart, another crash) still leaves it to recover. - let entries = list(&dir); - assert_eq!(entries.iter().map(|e| e.0).collect::>(), [st.uid]); - let mut s3 = Session::new(); - assert_eq!(open(&mut s3, &dir).unwrap().len(), 1); - assert_eq!(s3.doc().unwrap().doc.spreads[0].items.len(), 1); - assert!(s3.doc().unwrap().is_dirty()); - assert_eq!(list(&dir).len(), 1, "recovering twice never duplicates an entry"); - discard(&dir, s3.doc().unwrap().uid); - // Saving a document drops its entry. - s.recovery_dir = Some(dir.clone()); - assert_eq!(save(&s, &dir).unwrap(), 1); - let path = dir.join("saved.designcraft"); - s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - assert!(list(&dir).is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } - - #[test] - fn a_recovered_document_survives_a_second_crash_before_autosave() { - let dir = tempfile::tempdir().unwrap(); - let original = unsaved("edits preserved after a second crash"); - assert_eq!(save(&original, dir.path()).unwrap(), 1); - let mut reopened = Session::new(); - assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); - drop(reopened); - let mut recovered_again = Session::new(); - assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); - assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); - assert!(recovered_again.documents().iter().all(DocState::is_dirty)); - assert_eq!(list(dir.path()).len(), 1); - } - - #[test] - fn opening_recovery_again_does_not_duplicate_live_documents() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); - let mut reopened = Session::new(); - reopened.recovery_dir = Some(dir.path().to_path_buf()); - assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); - assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); - assert_eq!(texts(&reopened), ["one recovered document"]); - assert_eq!(list(dir.path()).len(), 1); - } - - /// Directory collisions fail even for privileged runners. Creating the blocker in the - /// publisher occurs after UID reservation, which would otherwise skip an occupied path. - fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { - let mut attempts = 0; - let opened = open_with_publisher(s, dir, |path, bytes| { - assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); - std::fs::create_dir(path).unwrap(); - let result = publish_recovery_file(path, bytes, false); - std::fs::remove_dir(path).unwrap(); - assert!(result.is_err(), "a directory must obstruct actual recovery publication"); - attempts += 1; - result - }); - assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); - opened - } - - #[test] - fn a_failed_recovery_rewrite_preserves_the_original() { - let dir = tempfile::tempdir().unwrap(); - assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); - let entries = list(dir.path()); - let original = files(dir.path(), entries[0].0).0; - let bytes = std::fs::read(&original).unwrap(); - let mut reopened = Session::new(); - let result = open_with_failed_republication(&mut reopened, dir.path()); - assert_eq!(result.unwrap().len(), 1); - assert_eq!(texts(&reopened), ["keep the original"]); - assert_eq!(std::fs::read(original).unwrap(), bytes); - assert_eq!(list(dir.path()).len(), 1); - // Its source uid differs from the reopened uid, but explicit Close still discards it. - assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); - reopened.close_document(0); - assert!(list(dir.path()).is_empty()); - } - - #[cfg(unix)] - #[test] - fn recovery_data_is_private_to_the_user() { - use std::os::unix::fs::PermissionsExt; - - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - let s = dirty_session(); - assert_eq!(save(&s, &dir).unwrap(), 1); - - assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); - let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect(); - assert_eq!(entries.len(), 2); - for entry in entries { - let entry = entry.unwrap(); - assert_eq!(entry.metadata().unwrap().permissions().mode() & 0o777, 0o600); - } - } - - #[test] - fn existing_recovery_directory_is_used() { - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - std::fs::create_dir(&dir).unwrap(); - - assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); - assert_eq!(list(&dir).len(), 1); - assert!(std::fs::symlink_metadata(&dir).unwrap().file_type().is_dir()); - } - - #[test] - fn non_directory_recovery_path_is_rejected() { - let root = tempfile::tempdir().unwrap(); - let path = root.path().join("recovery"); - std::fs::write(&path, b"not a directory").unwrap(); - - assert!(save(&dirty_session(), &path).is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"not a directory"); - } - - #[cfg(unix)] - #[test] - fn existing_recovery_directory_permissions_are_preserved() { - use std::os::unix::fs::PermissionsExt; - - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - std::fs::create_dir(&dir).unwrap(); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap(); - - assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); - assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o750); - } - - #[cfg(unix)] - #[test] - fn symlink_recovery_directory_is_rejected() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().unwrap(); - let destination = root.path().join("destination"); - let link = root.path().join("recovery"); - std::fs::create_dir(&destination).unwrap(); - symlink(&destination, &link).unwrap(); - - assert!(save(&dirty_session(), &link).is_err()); - assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); - std::fs::write(destination.join("7.designcraft"), b"unchanged").unwrap(); - std::fs::write(destination.join("7.json"), b"{}").unwrap(); - assert!(list(&link).is_empty()); - discard(&link, 7); - assert_eq!(std::fs::read(destination.join("7.designcraft")).unwrap(), b"unchanged"); - assert!(open(&mut Session::new(), &link).is_err()); - assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); - } - - #[test] - fn a_run_has_a_locked_folder_of_its_own() { - let root = tempfile::tempdir().unwrap(); - let dir = root.path().join("recovery"); - let own = run_dir(&dir).unwrap(); - assert_eq!(own.parent(), Some(dir.as_path())); - assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); - let lock = std::fs::File::open(own.join(LOCK)).unwrap(); - assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); - assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); - } - } - - fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { - let root = tempfile::tempdir().unwrap(); - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - let live_uid = session.docs[0].uid; - let mut original = unsaved("retained old work"); - original.docs[0].uid = live_uid; - save(&original, root.path()).unwrap(); - session.recovery_dir = Some(root.path().to_path_buf()); - let recovered = open_with_failed_republication(&mut session, root.path()); - assert_eq!(recovered.unwrap().len(), 1); - assert_ne!(session.docs[1].uid, live_uid); - (session, root, live_uid) - } - - #[test] - fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { - let (mut session, root, live_uid) = failed_republication_session(); - let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); - let recovered_uid = session.docs[1].uid; - std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); - assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); - assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); - } - - #[test] - fn clean_autosave_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - assert!(!session.docs[0].is_dirty()); - assert!(session.docs[1].is_dirty()); - assert!(session.execute("file.recovery.save", &json!({})).is_err()); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn closing_a_coincident_document_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - session.execute("file.close", &json!({"index": 0})).unwrap(); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn saving_a_coincident_document_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn reverting_a_coincident_document_preserves_another_documents_retained_source() { - let (mut session, root, live_uid) = failed_republication_session(); - let checkpoint = files(root.path(), live_uid).0; - let original = std::fs::read(&checkpoint).unwrap(); - let saved = root.path().join("saved-other.designcraft"); - std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); - session.docs[0].path = Some(saved.to_string_lossy().into_owned()); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); - session.execute("file.revert", &json!({})).unwrap(); - assert_eq!(std::fs::read(checkpoint).unwrap(), original); - } - - #[test] - fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { - let (mut session, root, live_uid) = failed_republication_session(); - session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); - session.execute("file.activate", &json!({"index": 0})).unwrap(); - session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); - session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); - let checkpoint = files(root.path(), live_uid).0; - let expected = std::fs::read(&checkpoint).unwrap(); - session.execute("file.close", &json!({"index": 1})).unwrap(); - assert_eq!( - std::fs::read(checkpoint).unwrap(), - expected, - "closing the saved recovered document must not delete another document's checkpoint" - ); - } - - #[test] - fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { - let root = tempfile::tempdir().unwrap(); - let mut session = unsaved("bounded checkpoints"); - for n in 0..MAX_RECOVERY_ENTRIES { - session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); - } - let excess = root.path().join("excess"); - assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); - assert!(!excess.exists(), "reject before creating or writing the next directory"); - let first_alias = root.path().join("0").join("."); - session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); - for n in 0..MAX_RECOVERY_ENTRIES { - assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); - } - assert!(session.recovery_dir.is_none()); - session.execute("file.close", &json!({})).unwrap(); - for n in 0..MAX_RECOVERY_ENTRIES { - assert!(list(&root.path().join(n.to_string())).is_empty()); - } - } - - fn restart_fixture() -> tempfile::TempDir { - let root = tempfile::tempdir().unwrap(); - let mut first = unsaved("restart first"); - first.docs[0].uid = 1; - save(&first, root.path()).unwrap(); - let mut second = unsaved("restart second"); - second.docs[0].uid = 2; - save(&second, root.path()).unwrap(); - root - } - - fn persisted_texts(dir: &Path) -> Vec { - let mut result = Vec::new(); - for (uid, _) in list(dir) { - let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); - result.extend(doc.stories.values().map(|story| story.text.clone())); - } - result.sort(); - result - } - - #[test] - #[ignore = "subprocess helper, executed by the restart recovery regression tests"] - fn restart_recovery_child() { - let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); - let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); - if mode == "occupied_destination" { - let owned_root = tempfile::tempdir().unwrap(); - let destination = run_dir(owned_root.path()).unwrap(); - let mut existing = unsaved("already in destination"); - existing.docs[0].uid = 3; - save(&existing, &destination).unwrap(); - let (existing_doc, existing_meta) = files(&destination, 3); - let doc_bytes = std::fs::read(&existing_doc).unwrap(); - let meta_bytes = std::fs::read(&existing_meta).unwrap(); - let mut session = Session::new(); - session.recovery_dir = Some(destination.clone()); - let opened = open(&mut session, &source).unwrap(); - assert_eq!(opened.len(), 2); - assert_eq!(texts(&session), ["restart first", "restart second"]); - assert!(session.docs.iter().all(|document| document.uid != 3)); - assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); - assert!(list(&source).is_empty()); - session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); - session.execute("file.close", &json!({"index": opened[0]})).unwrap(); - assert_eq!(list(&destination).len(), 1); - assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); - assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); - return; - } - let mut session = Session::new(); - if mode == "advance" || mode == "coincident_live" { - session.execute("file.new", &json!({})).unwrap(); - assert_eq!(session.docs[0].uid, 1); - } - let owned_root; - let destination = match mode.as_str() { - "cross_dir" => { - owned_root = tempfile::tempdir().unwrap(); - let own = run_dir(owned_root.path()).unwrap(); - session.recovery_dir = Some(own.clone()); - own - } - "alias" => { - let alias = source.join("."); - session.recovery_dir = Some(alias.clone()); - alias - } - "coincident_live" => { - session.recovery_dir = Some(source.clone()); - source.clone() - } - _ => source.clone(), - }; - let opened = open(&mut session, &source).unwrap(); - assert_eq!(opened.len(), 2); - assert_eq!(texts(&session), ["restart first", "restart second"]); - assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); - assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); - if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { - let was_disabled = session.recovery_dir.is_none(); - if mode == "cross_dir" { - assert!(list(&source).is_empty()); - } - // A copy does not consume recovery. A successful Save and a Close do. - session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(list(&destination).len(), 2); - session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); - assert_eq!(list(&destination).len(), 1); - if mode == "revert" { - session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); - session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); - assert_eq!(list(&destination).len(), 2); - session.execute("file.revert", &json!({})).unwrap(); - assert_eq!(list(&destination).len(), 1); - } - session.execute("file.close", &json!({"index": opened[0]})).unwrap(); - assert!(list(&destination).is_empty()); - assert!(list(&source).is_empty()); - assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); - } - } - - fn run_restart_case(mode: &str) { - let source = restart_fixture(); - if mode == "max_uid" { - let (doc, meta) = files(source.path(), 2); - let (max_doc, max_meta) = files(source.path(), u64::MAX); - std::fs::rename(doc, max_doc).unwrap(); - std::fs::rename(meta, max_meta).unwrap(); - } - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) - .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) - .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) - .output() - .unwrap(); - assert!( - output.status.success(), - "fresh child failed: {}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let child_stdout = String::from_utf8_lossy(&output.stdout); - assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); - } - - #[test] - fn restart_uid_collision_cannot_delete_the_new_snapshot() { - run_restart_case("fresh"); - } - #[test] - fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { - run_restart_case("advance"); - } - #[test] - fn cross_directory_recovery_uses_the_configured_destination() { - run_restart_case("cross_dir"); - } - #[test] - fn cross_directory_recovery_preserves_occupied_destination_entries() { - run_restart_case("occupied_destination"); - } - #[test] - fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { - run_restart_case("manual"); - } - #[test] - fn recovery_directory_aliases_do_not_delete_new_snapshots() { - run_restart_case("alias"); - } - #[test] - fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { - run_restart_case("max_uid"); - } - #[test] - fn a_coincident_live_uid_is_not_recovered_entry_provenance() { - run_restart_case("coincident_live"); - } - #[test] - fn manual_recovery_revert_removes_its_previous_checkpoint() { - run_restart_case("revert"); - } - - /// The recovery folder was cleared while the app runs: the next save makes the run's folder - /// again, and its lock with it. - #[cfg(unix)] - #[test] - fn a_removed_run_folder_is_made_again_with_its_lock() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_dir_all(&own).unwrap(); - assert_eq!(save(&dirty_session(), &own).unwrap(), 1); - let lock = std::fs::File::open(own.join(LOCK)).unwrap(); - assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); - } - - /// Replacing the name of a live run's lock must not authorize writes under the old handle. - #[cfg(unix)] - #[test] - fn save_rejects_a_run_whose_lock_was_replaced() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - let replacement = new_lock(&own).unwrap(); - replacement.try_lock().unwrap(); - assert!(save(&dirty_session(), &own).is_err()); - assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); - } - - #[cfg(windows)] - #[test] - fn an_exclusively_locked_windows_recovery_file_validates() { - let root = tempfile::tempdir().unwrap(); - let lock = new_lock(root.path()).unwrap(); - lock.try_lock().unwrap(); - validate_lock(root.path(), &lock).unwrap(); - let named = std::fs::File::open(root.path().join(LOCK)).unwrap(); - assert!(matches!(named.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "validation must retain the exclusive lock"); - } - - #[cfg(windows)] - #[test] - fn windows_recovery_rejects_a_replacement_with_the_same_marker() { - let root = tempfile::tempdir().unwrap(); - let lock = new_lock(root.path()).unwrap(); - let marker = std::fs::read(root.path().join(LOCK)).unwrap(); - lock.try_lock().unwrap(); - let moved = root.path().join("original-lock"); - std::fs::rename(root.path().join(LOCK), &moved).unwrap(); - std::fs::write(root.path().join(LOCK), marker).unwrap(); - assert!(validate_lock(root.path(), &lock).is_err(), "matching marker bytes do not make another file our lock"); - std::fs::remove_file(root.path().join(LOCK)).unwrap(); - assert!(validate_lock(root.path(), &lock).is_err(), "a missing lock must not validate"); - } - - #[cfg(windows)] - #[test] - fn windows_recovery_does_not_write_or_discard_a_replacement_runs_entries() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::rename(own.join(LOCK), own.join("original-lock")).unwrap(); - let replacement = new_lock(&own).unwrap(); - replacement.try_lock().unwrap(); - let session = dirty_session(); - let (doc, meta) = files(&own, session.docs[0].uid); - std::fs::write(&doc, b"another owner's document").unwrap(); - std::fs::write(&meta, b"{}").unwrap(); - assert!(save(&session, &own).is_err()); - discard(&own, session.docs[0].uid); - assert_eq!(std::fs::read(&doc).unwrap(), b"another owner's document"); - assert_eq!(std::fs::read(&meta).unwrap(), b"{}"); - } - - /// A blocked attempt to recreate a run's lock must stop the save before it writes documents. - #[cfg(unix)] - #[test] - fn save_rejects_a_run_whose_lock_cannot_be_recreated() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - std::fs::create_dir(own.join(LOCK)).unwrap(); - assert!(save(&dirty_session(), &own).is_err()); - assert!(list(&own).is_empty()); - } - - #[cfg(unix)] - #[test] - fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - let replacement = new_lock(&own).unwrap(); - replacement.try_lock().unwrap(); - let (doc, meta) = files(&own, 1); - std::fs::write(&doc, b"the replacement owner's document").unwrap(); - std::fs::write(&meta, b"{}").unwrap(); - discard(&own, 1); - assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); - assert!(meta.exists()); - } - - #[cfg(unix)] - #[test] - fn saving_a_run_rejects_a_symlinked_lock() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - std::fs::remove_file(own.join(LOCK)).unwrap(); - let unrelated = root.path().join("unrelated"); - std::fs::write(&unrelated, b"unrelated file").unwrap(); - std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); - assert!(save(&dirty_session(), &own).is_err()); - assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); - } - - #[cfg(unix)] - #[test] - fn a_replaced_run_directory_is_not_claimed_or_overwritten() { - let root = tempfile::tempdir().unwrap(); - let own = run_dir(root.path()).unwrap(); - let session = dirty_session(); - let uid = session.docs[0].uid; - std::fs::remove_dir_all(&own).unwrap(); - std::fs::create_dir(&own).unwrap(); - let (doc, _) = files(&own, uid); - std::fs::write(&doc, b"another owner's document").unwrap(); - assert!(save(&session, &own).is_err()); - assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); - assert!(!own.join(LOCK).exists()); - } - - #[cfg(unix)] - #[test] - fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().unwrap(); - let destination = root.path().join("destination"); - let link = root.path().join("recovery"); - std::fs::create_dir(&destination).unwrap(); - symlink(&destination, &link).unwrap(); - - assert!(run_dir(&link).is_err()); - assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); - } - - fn unsaved(text: &str) -> Session { - let mut session = Session::new(); - session.execute("file.new", &json!({})).unwrap(); - session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); - session - } - - fn texts(s: &Session) -> Vec { - let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); - texts.sort(); - texts - } - - /// What a run that has ended leaves: its folder with its lock, free now, and the entry of - /// one unsaved document, numbered `uid` as that process numbered it. - fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { - let dir = root.join(name); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join(LOCK), b"").unwrap(); - let mut s = unsaved(text); - s.docs[0].uid = uid; - assert_eq!(save(&s, &dir).unwrap(), 1); - dir - } - - /// Every process numbers its first document 1: two running apps both wrote `1.designcraft` - /// into the one recovery folder, and only the document written last was recovered. - #[test] - fn documents_of_two_apps_that_ended_are_both_recovered() { - let root = tempfile::tempdir().unwrap(); - let first = run_left(root.path(), "run-7-0", "first app", 1); - let second = run_left(root.path(), "run-8-0", "second app", 1); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 2); - assert_eq!(texts(&s), ["first app", "second app"]); - assert!(s.documents().iter().all(DocState::is_dirty)); - assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); - } - - /// An app that started reopened the unsaved documents of one still running, and removed - /// its entries. - #[test] - fn a_running_apps_recovery_data_is_left_alone() { - let root = tempfile::tempdir().unwrap(); - let running = run_left(root.path(), "run-7-0", "still open", 1); - let lock = std::fs::File::open(running.join(LOCK)).unwrap(); - lock.try_lock().unwrap(); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert!(open(&mut s, &own).unwrap().is_empty()); - assert_eq!(list(&running).len(), 1); - // Once it has ended, its document is recovered. - drop(lock); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(texts(&s), ["still open"]); - assert!(!running.exists()); - } - - /// A process id comes round again: the folder a run left under it is not this run's. - #[test] - fn a_folder_left_under_this_process_id_is_not_reused() { - let root = tempfile::tempdir().unwrap(); - let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); - let own = run_dir(root.path()).unwrap(); - assert_ne!(own, left); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(texts(&s), ["left behind"]); - } - - /// Entries written straight into the recovery folder, as before runs had folders. - #[test] - fn recovery_data_of_an_earlier_version_is_recovered() { - let root = tempfile::tempdir().unwrap(); - assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(texts(&s), ["earlier version"]); - assert!(list(root.path()).is_empty()); - } - - /// An entry that can't be opened stays on disk, in this run's folder now. - #[test] - fn an_unreadable_entry_of_a_run_that_ended_is_kept() { - let root = tempfile::tempdir().unwrap(); - let left = run_left(root.path(), "run-7-0", "readable", 1); - std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); - assert!(!left.exists()); - } - - /// Only a folder named as a run's, with a lock that is free, is a run that ended. - #[test] - fn what_is_no_run_that_ended_is_passed_over() { - let root = tempfile::tempdir().unwrap(); - std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); - // An app that is starting has made its folder and not yet its lock. - let starting = root.path().join("run-2-0"); - std::fs::create_dir(&starting).unwrap(); - let other = run_left(root.path(), "backup", "not a run", 1); - #[cfg(unix)] - let (_elsewhere, linked) = { - let elsewhere = tempfile::tempdir().unwrap(); - let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); - std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); - (elsewhere, linked) - }; - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert!(open(&mut s, &own).unwrap().is_empty()); - assert!(starting.exists()); - assert_eq!(list(&other).len(), 1); - #[cfg(unix)] - assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); - } - - /// The metadata goes with its document: a document taken over remembers its file. - #[test] - fn a_document_taken_over_remembers_its_file() { - let root = tempfile::tempdir().unwrap(); - let file = root.path().join("layout.designcraft"); - let recovery = root.path().join("recovery"); - let left = recovery.join("run-7-0"); - std::fs::create_dir_all(&left).unwrap(); - std::fs::write(left.join(LOCK), b"").unwrap(); - let mut a = unsaved("saved once"); - a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); - a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); - assert_eq!(save(&a, &left).unwrap(), 1); - let own = run_dir(&recovery).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 1); - assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); - } - - /// The folder of a run that ended goes with what the app left in it. A file the app did not - /// write keeps the folder, and is not deleted. - #[test] - fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { - let root = tempfile::tempdir().unwrap(); - let emptied = run_left(root.path(), "run-7-0", "moved", 1); - std::fs::write(emptied.join("9.json"), b"{}").unwrap(); - std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); - let kept = run_left(root.path(), "run-8-0", "moved too", 1); - std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); - let own = run_dir(root.path()).unwrap(); - let mut s = Session::new(); - assert_eq!(open(&mut s, &own).unwrap().len(), 2); - assert!(!emptied.exists()); - assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); - assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); - } - - /// A folder that is there is another app's or was left behind, lock or no lock: a run's own - /// is one it made itself. - #[test] - fn a_folder_that_is_there_is_not_made_this_runs() { - let root = tempfile::tempdir().unwrap(); - let left = root.path().join(format!("run-{}-0", std::process::id())); - std::fs::create_dir(&left).unwrap(); - std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); - let own = run_dir(root.path()).unwrap(); - assert_ne!(own, left); - assert!(!left.join(LOCK).exists()); - assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); - } - - /// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered - /// 7 too: document uids start at 1 in every process. - fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) { - let root = tempfile::tempdir().unwrap(); - let (doc, meta) = files(root.path(), 7); - std::fs::write(&doc, b"another run's document").unwrap(); - std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap(); - (root, doc, meta) - } - - #[test] - fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() { - let (root, doc, meta) = chosen_folder_with_another_runs_entry(); - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.docs[0].uid = 7; - assert!(!s.docs[0].is_dirty()); - s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); - s.execute("file.close", &json!({})).unwrap(); - assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); - assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); - } - - #[test] - fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() { - let (root, doc, meta) = chosen_folder_with_another_runs_entry(); - let mut s = unsaved("this run's work"); - s.docs[0].uid = 7; - assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); - assert!(save(&s, root.path()).is_err()); - assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); - assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); - } - - /// A chosen folder that is a running app's run folder is that app's. - #[test] - fn a_folder_another_running_app_holds_is_refused() { - let root = tempfile::tempdir().unwrap(); - let held = new_lock(root.path()).unwrap(); - held.try_lock().unwrap(); - assert!(save(&dirty_session(), root.path()).is_err()); - assert!(list(root.path()).is_empty()); - } -} +mod tests; #[cfg(test)] mod file_tests { diff --git a/crates/engine/src/recovery/tests.rs b/crates/engine/src/recovery/tests.rs new file mode 100644 index 000000000..395e8385b --- /dev/null +++ b/crates/engine/src/recovery/tests.rs @@ -0,0 +1,824 @@ +use super::*; + +fn dirty_session() -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + session +} + +#[test] +fn recovers_unsaved_documents() { + let dir = std::env::temp_dir().join(format!("dc-recovery-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + // Only the edited document is written. + s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&s, &dir).unwrap(), 1); + assert_eq!(list(&dir).len(), 1); + // "Crash": a new session reopens it, unsaved, with the item. + let mut s2 = Session::new(); + let opened = open(&mut s2, &dir).unwrap(); + assert_eq!(opened.len(), 1); + let st = s2.doc().unwrap(); + assert!(st.is_dirty()); + assert_eq!(st.doc.spreads[0].items.len(), 1); + // The entry moved to the reopened document: a session that ends before its first + // recovery save (a restart, another crash) still leaves it to recover. + let entries = list(&dir); + assert_eq!(entries.iter().map(|e| e.0).collect::>(), [st.uid]); + let mut s3 = Session::new(); + assert_eq!(open(&mut s3, &dir).unwrap().len(), 1); + assert_eq!(s3.doc().unwrap().doc.spreads[0].items.len(), 1); + assert!(s3.doc().unwrap().is_dirty()); + assert_eq!(list(&dir).len(), 1, "recovering twice never duplicates an entry"); + discard(&dir, s3.doc().unwrap().uid); + // Saving a document drops its entry. + s.recovery_dir = Some(dir.clone()); + assert_eq!(save(&s, &dir).unwrap(), 1); + let path = dir.join("saved.designcraft"); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(list(&dir).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_recovered_document_survives_a_second_crash_before_autosave() { + let dir = tempfile::tempdir().unwrap(); + let original = unsaved("edits preserved after a second crash"); + assert_eq!(save(&original, dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + drop(reopened); + let mut recovered_again = Session::new(); + assert_eq!(open(&mut recovered_again, dir.path()).unwrap().len(), 1); + assert_eq!(texts(&recovered_again), ["edits preserved after a second crash"]); + assert!(recovered_again.documents().iter().all(DocState::is_dirty)); + assert_eq!(list(dir.path()).len(), 1); +} + +#[test] +fn opening_recovery_again_does_not_duplicate_live_documents() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("one recovered document"), dir.path()).unwrap(), 1); + let mut reopened = Session::new(); + reopened.recovery_dir = Some(dir.path().to_path_buf()); + assert_eq!(open(&mut reopened, dir.path()).unwrap().len(), 1); + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + assert_eq!(texts(&reopened), ["one recovered document"]); + assert_eq!(list(dir.path()).len(), 1); +} + +/// Directory collisions fail even for privileged runners. Creating the blocker in the +/// publisher occurs after UID reservation, which would otherwise skip an occupied path. +fn open_with_failed_republication(s: &mut Session, dir: &Path) -> Result> { + let mut attempts = 0; + let opened = open_with_publisher(s, dir, |path, bytes| { + assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("designcraft")); + std::fs::create_dir(path).unwrap(); + let result = publish_recovery_file(path, bytes, false); + std::fs::remove_dir(path).unwrap(); + assert!(result.is_err(), "a directory must obstruct actual recovery publication"); + attempts += 1; + result + }); + assert_eq!(attempts, 1, "the fixture must exercise the actual failed publication"); + opened +} + +#[test] +fn a_failed_recovery_rewrite_preserves_the_original() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("keep the original"), dir.path()).unwrap(), 1); + let entries = list(dir.path()); + let original = files(dir.path(), entries[0].0).0; + let bytes = std::fs::read(&original).unwrap(); + let mut reopened = Session::new(); + let result = open_with_failed_republication(&mut reopened, dir.path()); + assert_eq!(result.unwrap().len(), 1); + assert_eq!(texts(&reopened), ["keep the original"]); + assert_eq!(std::fs::read(original).unwrap(), bytes); + assert_eq!(list(dir.path()).len(), 1); + // Its source uid differs from the reopened uid, but explicit Close still discards it. + assert!(open(&mut reopened, dir.path()).unwrap().is_empty()); + reopened.close_document(0); + assert!(list(dir.path()).is_empty()); +} + +#[cfg(unix)] +#[test] +fn recovery_data_is_private_to_the_user() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let s = dirty_session(); + assert_eq!(save(&s, &dir).unwrap(), 1); + + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o700); + let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().collect(); + assert_eq!(entries.len(), 2); + for entry in entries { + let entry = entry.unwrap(); + assert_eq!(entry.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } +} + +#[test] +fn existing_recovery_directory_is_used() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + std::fs::create_dir(&dir).unwrap(); + + assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); + assert_eq!(list(&dir).len(), 1); + assert!(std::fs::symlink_metadata(&dir).unwrap().file_type().is_dir()); +} + +#[test] +fn non_directory_recovery_path_is_rejected() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("recovery"); + std::fs::write(&path, b"not a directory").unwrap(); + + assert!(save(&dirty_session(), &path).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"not a directory"); +} + +#[cfg(unix)] +#[test] +fn existing_recovery_directory_permissions_are_preserved() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap(); + + assert_eq!(save(&dirty_session(), &dir).unwrap(), 1); + assert_eq!(std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777, 0o750); +} + +#[cfg(unix)] +#[test] +fn symlink_recovery_directory_is_rejected() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(save(&dirty_session(), &link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); + std::fs::write(destination.join("7.designcraft"), b"unchanged").unwrap(); + std::fs::write(destination.join("7.json"), b"{}").unwrap(); + assert!(list(&link).is_empty()); + discard(&link, 7); + assert_eq!(std::fs::read(destination.join("7.designcraft")).unwrap(), b"unchanged"); + assert!(open(&mut Session::new(), &link).is_err()); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); +} + +#[test] +fn a_run_has_a_locked_folder_of_its_own() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("recovery"); + let own = run_dir(&dir).unwrap(); + assert_eq!(own.parent(), Some(dir.as_path())); + assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run"); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600); + } +} + +fn failed_republication_session() -> (Session, tempfile::TempDir, u64) { + let root = tempfile::tempdir().unwrap(); + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + let live_uid = session.docs[0].uid; + let mut original = unsaved("retained old work"); + original.docs[0].uid = live_uid; + save(&original, root.path()).unwrap(); + session.recovery_dir = Some(root.path().to_path_buf()); + let recovered = open_with_failed_republication(&mut session, root.path()); + assert_eq!(recovered.unwrap().len(), 1); + assert_ne!(session.docs[1].uid, live_uid); + (session, root, live_uid) +} + +#[test] +fn retained_source_survives_a_coincident_live_uid_checkpoint_failure() { + let (mut session, root, live_uid) = failed_republication_session(); + let original = std::fs::read(files(root.path(), live_uid).0).unwrap(); + let recovered_uid = session.docs[1].uid; + std::fs::create_dir(files(root.path(), recovered_uid).0).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + assert!(session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(files(root.path(), live_uid).0).unwrap(), original); +} + +#[test] +fn clean_autosave_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + assert!(!session.docs[0].is_dirty()); + assert!(session.docs[1].is_dirty()); + assert!(session.execute("file.recovery.save", &json!({})).is_err()); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn closing_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn saving_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved-other.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn reverting_a_coincident_document_preserves_another_documents_retained_source() { + let (mut session, root, live_uid) = failed_republication_session(); + let checkpoint = files(root.path(), live_uid).0; + let original = std::fs::read(&checkpoint).unwrap(); + let saved = root.path().join("saved-other.designcraft"); + std::fs::write(&saved, designcraft_format::save(&session.docs[0].doc).unwrap()).unwrap(); + session.docs[0].path = Some(saved.to_string_lossy().into_owned()); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100]})).unwrap(); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), original); +} + +#[test] +fn retained_source_provenance_is_consumed_before_its_uid_is_reused() { + let (mut session, root, live_uid) = failed_republication_session(); + session.execute("file.saveAs", &json!({"path": root.path().join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.activate", &json!({"index": 0})).unwrap(); + session.execute("frame.create", &json!({"rect": [0,0,100,100], "content": "text", "text": "other live work"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + let checkpoint = files(root.path(), live_uid).0; + let expected = std::fs::read(&checkpoint).unwrap(); + session.execute("file.close", &json!({"index": 1})).unwrap(); + assert_eq!(std::fs::read(checkpoint).unwrap(), expected, "closing the saved recovered document must not delete another document's checkpoint"); +} + +#[test] +fn explicit_checkpoint_tracking_is_bounded_and_deduplicated_before_writes() { + let root = tempfile::tempdir().unwrap(); + let mut session = unsaved("bounded checkpoints"); + for n in 0..MAX_RECOVERY_ENTRIES { + session.execute("file.recovery.save", &json!({"dir": root.path().join(n.to_string()).to_string_lossy()})).unwrap(); + } + let excess = root.path().join("excess"); + assert!(session.execute("file.recovery.save", &json!({"dir": excess.to_string_lossy()})).is_err()); + assert!(!excess.exists(), "reject before creating or writing the next directory"); + let first_alias = root.path().join("0").join("."); + session.execute("file.recovery.save", &json!({"dir": first_alias.to_string_lossy()})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert_eq!(persisted_texts(&root.path().join(n.to_string())), ["bounded checkpoints"]); + } + assert!(session.recovery_dir.is_none()); + session.execute("file.close", &json!({})).unwrap(); + for n in 0..MAX_RECOVERY_ENTRIES { + assert!(list(&root.path().join(n.to_string())).is_empty()); + } +} + +fn restart_fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + let mut first = unsaved("restart first"); + first.docs[0].uid = 1; + save(&first, root.path()).unwrap(); + let mut second = unsaved("restart second"); + second.docs[0].uid = 2; + save(&second, root.path()).unwrap(); + root +} + +fn persisted_texts(dir: &Path) -> Vec { + let mut result = Vec::new(); + for (uid, _) in list(dir) { + let doc = crate::cmd::from_bytes(&std::fs::read(files(dir, uid).0).unwrap()).unwrap(); + result.extend(doc.stories.values().map(|story| story.text.clone())); + } + result.sort(); + result +} + +#[test] +#[ignore = "subprocess helper, executed by the restart recovery regression tests"] +fn restart_recovery_child() { + let source = PathBuf::from(std::env::var("DESIGNCRAFT_TEST_RECOVERY_SOURCE").unwrap()); + let mode = std::env::var("DESIGNCRAFT_TEST_RECOVERY_MODE").unwrap(); + if mode == "occupied_destination" { + let owned_root = tempfile::tempdir().unwrap(); + let destination = run_dir(owned_root.path()).unwrap(); + let mut existing = unsaved("already in destination"); + existing.docs[0].uid = 3; + save(&existing, &destination).unwrap(); + let (existing_doc, existing_meta) = files(&destination, 3); + let doc_bytes = std::fs::read(&existing_doc).unwrap(); + let meta_bytes = std::fs::read(&existing_meta).unwrap(); + let mut session = Session::new(); + session.recovery_dir = Some(destination.clone()); + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|document| document.uid != 3)); + assert_eq!(persisted_texts(&destination), ["already in destination", "restart first", "restart second"]); + assert!(list(&source).is_empty()); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert_eq!(list(&destination).len(), 1); + assert_eq!(std::fs::read(existing_doc).unwrap(), doc_bytes); + assert_eq!(std::fs::read(existing_meta).unwrap(), meta_bytes); + return; + } + let mut session = Session::new(); + if mode == "advance" || mode == "coincident_live" { + session.execute("file.new", &json!({})).unwrap(); + assert_eq!(session.docs[0].uid, 1); + } + let owned_root; + let destination = match mode.as_str() { + "cross_dir" => { + owned_root = tempfile::tempdir().unwrap(); + let own = run_dir(owned_root.path()).unwrap(); + session.recovery_dir = Some(own.clone()); + own + } + "alias" => { + let alias = source.join("."); + session.recovery_dir = Some(alias.clone()); + alias + } + "coincident_live" => { + session.recovery_dir = Some(source.clone()); + source.clone() + } + _ => source.clone(), + }; + let opened = open(&mut session, &source).unwrap(); + assert_eq!(opened.len(), 2); + assert_eq!(texts(&session), ["restart first", "restart second"]); + assert_eq!(persisted_texts(&destination), ["restart first", "restart second"]); + assert!(session.docs.iter().all(|d| d.uid > 0 && d.uid < u64::MAX)); + if matches!(mode.as_str(), "cross_dir" | "manual" | "alias" | "revert") { + let was_disabled = session.recovery_dir.is_none(); + if mode == "cross_dir" { + assert!(list(&source).is_empty()); + } + // A copy does not consume recovery. A successful Save and a Close do. + session.execute("file.saveACopy", &json!({"path": source.join("copy.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.saveAs", &json!({"path": source.join("saved.designcraft").to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 1); + if mode == "revert" { + session.execute("frame.create", &json!({"rect": [0, 0, 100, 100], "content": "text", "text": "after saving"})).unwrap(); + session.execute("file.recovery.save", &json!({"dir": destination.to_string_lossy()})).unwrap(); + assert_eq!(list(&destination).len(), 2); + session.execute("file.revert", &json!({})).unwrap(); + assert_eq!(list(&destination).len(), 1); + } + session.execute("file.close", &json!({"index": opened[0]})).unwrap(); + assert!(list(&destination).is_empty()); + assert!(list(&source).is_empty()); + assert_eq!(session.recovery_dir.is_none(), was_disabled, "manual recovery must not configure autosave"); + } +} + +fn run_restart_case(mode: &str) { + let source = restart_fixture(); + if mode == "max_uid" { + let (doc, meta) = files(source.path(), 2); + let (max_doc, max_meta) = files(source.path(), u64::MAX); + std::fs::rename(doc, max_doc).unwrap(); + std::fs::rename(meta, max_meta).unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "recovery::tests::restart_recovery_child", "--ignored", "--nocapture"]) + .env("DESIGNCRAFT_TEST_RECOVERY_SOURCE", source.path()) + .env("DESIGNCRAFT_TEST_RECOVERY_MODE", mode) + .output() + .unwrap(); + assert!(output.status.success(), "fresh child failed: {}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + let child_stdout = String::from_utf8_lossy(&output.stdout); + assert!(child_stdout.contains("test result: ok. 1 passed; 0 failed;"), "the fresh-process helper did not run exactly once: {child_stdout}"); +} + +#[test] +fn restart_uid_collision_cannot_delete_the_new_snapshot() { + run_restart_case("fresh"); +} +#[test] +fn restart_uid_collision_cannot_overwrite_an_unopened_entry() { + run_restart_case("advance"); +} +#[test] +fn cross_directory_recovery_uses_the_configured_destination() { + run_restart_case("cross_dir"); +} +#[test] +fn cross_directory_recovery_preserves_occupied_destination_entries() { + run_restart_case("occupied_destination"); +} +#[test] +fn manual_recovery_save_and_close_clean_up_without_enabling_autosave() { + run_restart_case("manual"); +} +#[test] +fn recovery_directory_aliases_do_not_delete_new_snapshots() { + run_restart_case("alias"); +} +#[test] +fn a_maximum_source_uid_does_not_advance_or_overflow_the_allocator() { + run_restart_case("max_uid"); +} +#[test] +fn a_coincident_live_uid_is_not_recovered_entry_provenance() { + run_restart_case("coincident_live"); +} +#[test] +fn manual_recovery_revert_removes_its_previous_checkpoint() { + run_restart_case("revert"); +} + +/// The recovery folder was cleared while the app runs: the next save makes the run's folder +/// again, and its lock with it. +#[cfg(unix)] +#[test] +fn a_removed_run_folder_is_made_again_with_its_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_dir_all(&own).unwrap(); + assert_eq!(save(&dirty_session(), &own).unwrap(), 1); + let lock = std::fs::File::open(own.join(LOCK)).unwrap(); + assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock))); +} + +/// Replacing the name of a live run's lock must not authorize writes under the old handle. +#[cfg(unix)] +#[test] +fn save_rejects_a_run_whose_lock_was_replaced() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data"); +} + +#[cfg(windows)] +#[test] +fn an_exclusively_locked_windows_recovery_file_validates() { + let root = tempfile::tempdir().unwrap(); + let lock = new_lock(root.path()).unwrap(); + lock.try_lock().unwrap(); + validate_lock(root.path(), &lock).unwrap(); + let named = std::fs::File::open(root.path().join(LOCK)).unwrap(); + assert!(matches!(named.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "validation must retain the exclusive lock"); +} + +#[cfg(windows)] +#[test] +fn windows_recovery_rejects_a_replacement_with_the_same_marker() { + let root = tempfile::tempdir().unwrap(); + let lock = new_lock(root.path()).unwrap(); + let marker = std::fs::read(root.path().join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let moved = root.path().join("original-lock"); + std::fs::rename(root.path().join(LOCK), &moved).unwrap(); + std::fs::write(root.path().join(LOCK), marker).unwrap(); + assert!(validate_lock(root.path(), &lock).is_err(), "matching marker bytes do not make another file our lock"); + std::fs::remove_file(root.path().join(LOCK)).unwrap(); + assert!(validate_lock(root.path(), &lock).is_err(), "a missing lock must not validate"); +} + +#[cfg(windows)] +#[test] +fn windows_recovery_does_not_write_or_discard_a_replacement_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::rename(own.join(LOCK), own.join("original-lock")).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let session = dirty_session(); + let (doc, meta) = files(&own, session.docs[0].uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + assert!(save(&session, &own).is_err()); + discard(&own, session.docs[0].uid); + assert_eq!(std::fs::read(&doc).unwrap(), b"another owner's document"); + assert_eq!(std::fs::read(&meta).unwrap(), b"{}"); +} + +/// A blocked attempt to recreate a run's lock must stop the save before it writes documents. +#[cfg(unix)] +#[test] +fn save_rejects_a_run_whose_lock_cannot_be_recreated() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + std::fs::create_dir(own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert!(list(&own).is_empty()); +} + +#[cfg(unix)] +#[test] +fn discarding_a_document_does_not_touch_a_replaced_runs_entries() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let replacement = new_lock(&own).unwrap(); + replacement.try_lock().unwrap(); + let (doc, meta) = files(&own, 1); + std::fs::write(&doc, b"the replacement owner's document").unwrap(); + std::fs::write(&meta, b"{}").unwrap(); + discard(&own, 1); + assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document"); + assert!(meta.exists()); +} + +#[cfg(unix)] +#[test] +fn saving_a_run_rejects_a_symlinked_lock() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + std::fs::remove_file(own.join(LOCK)).unwrap(); + let unrelated = root.path().join("unrelated"); + std::fs::write(&unrelated, b"unrelated file").unwrap(); + std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap(); + assert!(save(&dirty_session(), &own).is_err()); + assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file"); +} + +#[cfg(unix)] +#[test] +fn a_replaced_run_directory_is_not_claimed_or_overwritten() { + let root = tempfile::tempdir().unwrap(); + let own = run_dir(root.path()).unwrap(); + let session = dirty_session(); + let uid = session.docs[0].uid; + std::fs::remove_dir_all(&own).unwrap(); + std::fs::create_dir(&own).unwrap(); + let (doc, _) = files(&own, uid); + std::fs::write(&doc, b"another owner's document").unwrap(); + assert!(save(&session, &own).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document"); + assert!(!own.join(LOCK).exists()); +} + +#[cfg(unix)] +#[test] +fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let destination = root.path().join("destination"); + let link = root.path().join("recovery"); + std::fs::create_dir(&destination).unwrap(); + symlink(&destination, &link).unwrap(); + + assert!(run_dir(&link).is_err()); + assert!(std::fs::read_dir(&destination).unwrap().next().is_none()); +} + +fn unsaved(text: &str) -> Session { + let mut session = Session::new(); + session.execute("file.new", &json!({})).unwrap(); + session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap(); + session +} + +fn texts(s: &Session) -> Vec { + let mut texts: Vec = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect(); + texts.sort(); + texts +} + +/// What a run that has ended leaves: its folder with its lock, free now, and the entry of +/// one unsaved document, numbered `uid` as that process numbered it. +fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(LOCK), b"").unwrap(); + let mut s = unsaved(text); + s.docs[0].uid = uid; + assert_eq!(save(&s, &dir).unwrap(), 1); + dir +} + +/// Every process numbers its first document 1: two running apps both wrote `1.designcraft` +/// into the one recovery folder, and only the document written last was recovered. +#[test] +fn documents_of_two_apps_that_ended_are_both_recovered() { + let root = tempfile::tempdir().unwrap(); + let first = run_left(root.path(), "run-7-0", "first app", 1); + let second = run_left(root.path(), "run-8-0", "second app", 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert_eq!(texts(&s), ["first app", "second app"]); + assert!(s.documents().iter().all(DocState::is_dirty)); + assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended"); +} + +/// An app that started reopened the unsaved documents of one still running, and removed +/// its entries. +#[test] +fn a_running_apps_recovery_data_is_left_alone() { + let root = tempfile::tempdir().unwrap(); + let running = run_left(root.path(), "run-7-0", "still open", 1); + let lock = std::fs::File::open(running.join(LOCK)).unwrap(); + lock.try_lock().unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert_eq!(list(&running).len(), 1); + // Once it has ended, its document is recovered. + drop(lock); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["still open"]); + assert!(!running.exists()); +} + +/// A process id comes round again: the folder a run left under it is not this run's. +#[test] +fn a_folder_left_under_this_process_id_is_not_reused() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["left behind"]); +} + +/// Entries written straight into the recovery folder, as before runs had folders. +#[test] +fn recovery_data_of_an_earlier_version_is_recovered() { + let root = tempfile::tempdir().unwrap(); + assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(texts(&s), ["earlier version"]); + assert!(list(root.path()).is_empty()); +} + +/// An entry that can't be opened stays on disk, in this run's folder now. +#[test] +fn an_unreadable_entry_of_a_run_that_ended_is_kept() { + let root = tempfile::tempdir().unwrap(); + let left = run_left(root.path(), "run-7-0", "readable", 1); + std::fs::write(left.join("2.designcraft"), b"not a document").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document")); + assert!(!left.exists()); +} + +/// Only a folder named as a run's, with a lock that is free, is a run that ended. +#[test] +fn what_is_no_run_that_ended_is_passed_over() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("run-1-0"), b"a file").unwrap(); + // An app that is starting has made its folder and not yet its lock. + let starting = root.path().join("run-2-0"); + std::fs::create_dir(&starting).unwrap(); + let other = run_left(root.path(), "backup", "not a run", 1); + #[cfg(unix)] + let (_elsewhere, linked) = { + let elsewhere = tempfile::tempdir().unwrap(); + let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1); + std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap(); + (elsewhere, linked) + }; + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert!(open(&mut s, &own).unwrap().is_empty()); + assert!(starting.exists()); + assert_eq!(list(&other).len(), 1); + #[cfg(unix)] + assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink()); +} + +/// The metadata goes with its document: a document taken over remembers its file. +#[test] +fn a_document_taken_over_remembers_its_file() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("layout.designcraft"); + let recovery = root.path().join("recovery"); + let left = recovery.join("run-7-0"); + std::fs::create_dir_all(&left).unwrap(); + std::fs::write(left.join(LOCK), b"").unwrap(); + let mut a = unsaved("saved once"); + a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap(); + assert_eq!(save(&a, &left).unwrap(), 1); + let own = run_dir(&recovery).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 1); + assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref())); +} + +/// The folder of a run that ended goes with what the app left in it. A file the app did not +/// write keeps the folder, and is not deleted. +#[test] +fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() { + let root = tempfile::tempdir().unwrap(); + let emptied = run_left(root.path(), "run-7-0", "moved", 1); + std::fs::write(emptied.join("9.json"), b"{}").unwrap(); + std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap(); + let kept = run_left(root.path(), "run-8-0", "moved too", 1); + std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap(); + let own = run_dir(root.path()).unwrap(); + let mut s = Session::new(); + assert_eq!(open(&mut s, &own).unwrap().len(), 2); + assert!(!emptied.exists()); + assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here"); + assert!(kept.join(LOCK).exists(), "still the folder of a run that ended"); +} + +/// A folder that is there is another app's or was left behind, lock or no lock: a run's own +/// is one it made itself. +#[test] +fn a_folder_that_is_there_is_not_made_this_runs() { + let root = tempfile::tempdir().unwrap(); + let left = root.path().join(format!("run-{}-0", std::process::id())); + std::fs::create_dir(&left).unwrap(); + std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap(); + let own = run_dir(root.path()).unwrap(); + assert_ne!(own, left); + assert!(!left.join(LOCK).exists()); + assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock"); +} + +/// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered +/// 7 too: document uids start at 1 in every process. +fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let (doc, meta) = files(root.path(), 7); + std::fs::write(&doc, b"another run's document").unwrap(); + std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap(); + (root, doc, meta) +} + +#[test] +fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.docs[0].uid = 7; + assert!(!s.docs[0].is_dirty()); + s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap(); + s.execute("file.close", &json!({})).unwrap(); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); +} + +#[test] +fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() { + let (root, doc, meta) = chosen_folder_with_another_runs_entry(); + let mut s = unsaved("this run's work"); + s.docs[0].uid = 7; + assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err()); + assert!(save(&s, root.path()).is_err()); + assert_eq!(std::fs::read(doc).unwrap(), b"another run's document"); + assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}"); +} + +/// A chosen folder that is a running app's run folder is that app's. +#[test] +fn a_folder_another_running_app_holds_is_refused() { + let root = tempfile::tempdir().unwrap(); + let held = new_lock(root.path()).unwrap(); + held.try_lock().unwrap(); + assert!(save(&dirty_session(), root.path()).is_err()); + assert!(list(root.path()).is_empty()); +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 0009b4adb..79b51e40a 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: ( let (nw, nh) = (px.0 as f64, px.1 as f64); let k = (r.width() / nw).max(r.height() / nh); it.content = Content::Graphic(Graphic { + layer_options: None, asset, size: (nw, nh), xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k), @@ -128,8 +129,8 @@ fn build() -> designcraft_doc::Result { gradient: Gradient { kind: GradientKind::Linear, stops: vec![ - GradientStop { offset: 0.0, color: Color::cmyk(0.62, 0.95, 0.30, 0.25), opacity: 1.0, midpoint: 0.5 }, - GradientStop { offset: 1.0, color: Color::cmyk(0.0, 0.62, 0.78, 0.0), opacity: 1.0, midpoint: 0.5 }, + GradientStop { swatch: Some("Ink Plum".into()), ..GradientStop::new(0.0, Color::cmyk(0.62, 0.95, 0.30, 0.25)) }, + GradientStop { swatch: Some("Sunset".into()), ..GradientStop::new(1.0, Color::cmyk(0.0, 0.62, 0.78, 0.0)) }, ], }, }, diff --git a/crates/engine/src/separations.rs b/crates/engine/src/separations.rs new file mode 100644 index 000000000..a13b8af4f --- /dev/null +++ b/crates/engine/src/separations.rs @@ -0,0 +1,251 @@ +//! Separations Preview: the plates the print PDF puts on film, read back from that PDF. +//! +//! A spread is exported as Export PDF writes it (one PDF page for the spread, with the +//! document bleed) into memory, and `craft_prepress::plates` separates that page: one 8-bit +//! plane per ink, the four process inks and every spot ink the page paints, after the Ink +//! Manager's aliases and spots to process. The preview shows those plates, so it shows what +//! the PDF prints, not what the document model intends. + +use std::sync::Arc; + +use craft_prepress::{CmsConversion, Fallback, InkId, Options, Plates, Process}; +use designcraft_compose::Cache; +use designcraft_doc::{Document, PageSide}; +use designcraft_geom::Rect; +use designcraft_pdf::PdfOptions; +pub use designcraft_pdf::Standard; + +use crate::{EngineError, Result}; + +/// The highest resolution a preview separates at, in pixels per inch. craft-prepress further +/// lowers it to fit 4 megapixels per plate. +pub const MAX_DPI: f32 = 300.0; +/// The lowest resolution a preview separates at. +pub const MIN_DPI: f32 = 9.0; +/// The color over-limit areas show in, sRGB. +pub const OVER_LIMIT: [u8; 3] = [230, 40, 40]; + +/// A spread separated into plates. +pub struct Sheet { + pub spread: usize, + /// The PDF page's area in spread coordinates: the spread's trim plus the document bleed. + pub area: Rect, + pub plates: Plates, + /// The printing condition the composite converts process plates through. + cms: CmsConversion, + /// Export warnings. + pub warnings: Vec, +} + +impl std::fmt::Debug for Sheet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Sheet").field("spread", &self.spread).field("area", &self.area).field("plates", &self.plates).finish_non_exhaustive() + } +} + +/// The area spread `si`'s print PDF page covers, in spread coordinates (the trim and the bleed, +/// as the PDF writer lays out a spread sheet). +fn sheet_area(doc: &Document, si: usize) -> Option { + let sp = doc.spreads.get(si)?; + let b = doc.settings.bleed.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // Bleed is top, bottom, inside, outside; single-sided pages read inside as left. + let lr = |side: PageSide| if side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) }; + let outer = sp.outer_page_indices(); + let l = outer.and_then(|(l, _)| sp.pages.get(l)).map_or(b[2], |p| lr(p.side).0); + let r = outer.and_then(|(_, r)| sp.pages.get(r)).map_or(b[3], |p| lr(p.side).1); + let trim = sp.bounds(); + Some(Rect::new(trim.x0 - l, trim.y0 - b[0], trim.x1 + r, trim.y1 + b[1])) +} + +/// The print profile: the working CMYK space's output intent, as PDF/X-4 export embeds it. +fn printing_condition() -> (Fallback, CmsConversion) { + let Ok(intent) = designcraft_color::cms::active().output_intent() else { return (Fallback::Fogra39, CmsConversion::fogra39()) }; + match CmsConversion::new(&intent.icc) { + Ok(cms) => (Fallback::Profile(Arc::from(intent.icc.as_ref())), cms), + Err(_) => (Fallback::Fogra39, CmsConversion::fogra39()), + } +} + +/// The Export PDF settings that change what a page prints. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Print { + pub standard: Standard, + pub compress_images: bool, + /// The transparency flattener's resolution, in ppi. + pub flatten: Option, +} + +/// Exports spread `si` as Export PDF prints it with `print` (one page for the spread, with +/// the document bleed, without printer's marks) and separates it at `dpi`, clamped to +/// [`MIN_DPI`]–[`MAX_DPI`]. +pub fn separate(doc: &Document, cache: &Cache, si: usize, dpi: f32, print: &Print) -> Result { + let sp = doc.spreads.get(si).ok_or_else(|| EngineError::Other(format!("spread {si} does not exist")))?; + let first = doc.first_page_of_spread(si); + let pages: Vec = (first..first.saturating_add(sp.pages.len())).collect(); + let area = sheet_area(doc, si).ok_or_else(|| EngineError::Other(format!("spread {si} does not exist")))?; + let opts = PdfOptions { + pages: Some(pages), + spreads: true, + bleed: true, + standard: print.standard, + compress_images: print.compress_images, + ..PdfOptions::default() + }; + let report = crate::cmd::print_pdf(doc, cache, &opts, print.flatten)?; + let dpi = if dpi.is_finite() { dpi.clamp(MIN_DPI, MAX_DPI) } else { MIN_DPI }; + let (fallback, cms) = printing_condition(); + let plates = craft_prepress::plates(&report.bytes, 0, dpi, &Options { overprint: true, fallback }) + .map_err(|e| EngineError::Other(format!("separations: {e}")))?; + Ok(Sheet { spread: si, area, plates, cms, warnings: report.warnings }) +} + +impl Sheet { + /// The ink a plate name denotes: a process ink by its name in any case (`cyan`), or a spot + /// ink by name. + pub fn ink(&self, name: &str) -> Option { + Process::ALL.into_iter().find(|p| p.name().eq_ignore_ascii_case(name)).map(InkId::process).or_else(|| self.plates.inks.lookup(name)) + } + + /// Every plate: name and whether it's a spot ink, process inks first. + pub fn inks(&self) -> Vec<(String, bool)> { + self.plates.inks.iter().map(|(_, ink)| (ink.name().to_string(), ink.is_spot())).collect() + } + + /// The preview as opaque RGBA8 (`plates.width` × `plates.height`): every ink composited, + /// or with `plate` one ink alone as gray film (black is 100%). With `ink_limit` (percent of + /// total ink) the image is gray and the areas over the limit show in [`OVER_LIMIT`]. + pub fn image(&self, plate: Option, ink_limit: Option) -> Vec { + let p = &self.plates; + let mut rgb = match plate.and_then(|id| p.plane(id)) { + Some(plane) => plane.iter().flat_map(|t| [255 - t; 3]).collect(), + None => p.composite(&[], &self.cms), + }; + if let Some(limit) = ink_limit.filter(|l| l.is_finite()) { + for px in rgb.as_chunks_mut::<3>().0 { + let [r, g, b] = px.map(u32::from); + let y = ((r * 77 + g * 150 + b * 29) >> 8) as u8; + *px = [y; 3]; + } + p.above(limit, &[]).paint(&mut rgb, OVER_LIMIT); + } + rgb.as_chunks::<3>().0.iter().flat_map(|[r, g, b]| [*r, *g, *b, 255]).collect() + } + + /// Converts a pixel position to spread coordinates. + pub fn to_spread(&self, x: u32, y: u32) -> designcraft_geom::Point { + let k = 72.0 / f64::from(self.plates.dpi.max(f32::MIN_POSITIVE)); + designcraft_geom::Point::new(self.area.x0 + f64::from(x) * k, self.area.y0 + f64::from(y) * k) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + /// A letter page with a Gold spot square at (72, 72), a cyan square at (300, 72) and a + /// 400% square at (72, 400), each 100 pt. + fn document() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Gold", "spot": true, "color": {"c": 0, "m": 20, "y": 80, "k": 10}})).unwrap(); + s.execute("swatch.create", &json!({"name": "Rich", "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); + for (rect, swatch) in [([72, 72, 172, 172], "Gold"), ([300, 72, 400, 172], "C=100 M=0 Y=0 K=0"), ([72, 400, 172, 500], "Rich")] { + let id = s.execute("frame.create", &json!({"rect": rect})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": swatch, "ids": [id]})).unwrap(); + } + s + } + + fn sheet(s: &Session) -> Sheet { + separate(&s.doc().unwrap().doc, &s.cache, 0, 36.0, &Print::default()).unwrap() + } + + /// The tint of `ink` at spread point (`x`, `y`). + fn tint(sheet: &Sheet, ink: &str, x: f64, y: f64) -> u8 { + let k = f64::from(sheet.plates.dpi) / 72.0; + let (px, py) = (((x - sheet.area.x0) * k) as u32, ((y - sheet.area.y0) * k) as u32); + sheet.plates.tint(sheet.ink(ink).unwrap(), px, py).unwrap() + } + + #[test] + fn the_plates_are_the_process_inks_and_the_spot_and_the_spot_plate_shows_only_the_spot() { + let s = document(); + let sheet = sheet(&s); + let names: Vec<(String, bool)> = sheet.inks(); + let expected: Vec<(String, bool)> = + [("Cyan", false), ("Magenta", false), ("Yellow", false), ("Black", false), ("Gold", true)].map(|(n, sp)| (n.to_string(), sp)).into(); + assert_eq!(names, expected); + assert!(tint(&sheet, "Gold", 122.0, 122.0) > 250, "Gold under the Gold square"); + assert_eq!(tint(&sheet, "Gold", 350.0, 122.0), 0, "no Gold under the cyan square"); + assert_eq!(tint(&sheet, "Gold", 122.0, 450.0), 0, "no Gold under the 400% square"); + assert_eq!(tint(&sheet, "cyan", 122.0, 122.0), 0, "the spot isn't separated into process"); + assert!(tint(&sheet, "cyan", 350.0, 122.0) > 250); + + // The Gold plate alone: film only where the Gold square is. + let img = sheet.image(sheet.ink("Gold"), None); + let at = |x: f64, y: f64| { + let k = f64::from(sheet.plates.dpi) / 72.0; + let i = (((y - sheet.area.y0) * k) as usize * sheet.plates.width as usize + ((x - sheet.area.x0) * k) as usize) * 4; + [img[i], img[i + 1], img[i + 2]] + }; + assert!(at(122.0, 122.0).iter().all(|v| *v < 5), "{:?}", at(122.0, 122.0)); + assert_eq!(at(350.0, 122.0), [255; 3]); + assert_eq!(at(122.0, 450.0), [255; 3]); + let inked = img.as_chunks::<4>().0.iter().filter(|px| px[0] < 128).count(); + let square = (100.0 * f64::from(sheet.plates.dpi) / 72.0).powi(2); + assert!((inked as f64 - square).abs() < square * 0.1, "{inked} dark pixels, the square has {square}"); + } + + #[test] + fn ink_limit_flags_the_400_percent_area() { + let mut s = document(); + let r = s.execute("ink.separations", &json!({"dpi": 36, "inkLimit": 300})).unwrap(); + assert!(r["maxTotal"].as_f64().unwrap() > 399.0, "{r}"); + let regions = r["overLimit"]["regions"].as_array().unwrap(); + assert_eq!(regions.len(), 1, "{r}"); + let (x, y) = (regions[0]["x"].as_f64().unwrap(), regions[0]["y"].as_f64().unwrap()); + assert!((x - 72.0).abs() < 3.0 && (y - 400.0).abs() < 3.0, "the 400% square: {r}"); + assert!(r["inks"].as_array().unwrap().iter().any(|i| i["name"] == "Gold" && i["spot"] == true)); + + let sheet = sheet(&s); + let img = sheet.image(None, Some(300.0)); + let k = f64::from(sheet.plates.dpi) / 72.0; + let at = |x: f64, y: f64| { + let i = (((y - sheet.area.y0) * k) as usize * sheet.plates.width as usize + ((x - sheet.area.x0) * k) as usize) * 4; + [img[i], img[i + 1], img[i + 2]] + }; + assert_eq!(at(122.0, 450.0), OVER_LIMIT); + let gray = at(350.0, 122.0); + assert!(gray[0] == gray[1] && gray[1] == gray[2], "under the limit the preview is gray: {gray:?}"); + } + + #[test] + fn a_spot_aliased_to_another_spot_prints_on_its_plate() { + let mut s = document(); + s.execute("swatch.create", &json!({"name": "Silver", "spot": true, "color": {"c": 0, "m": 0, "y": 0, "k": 40}})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [300, 400, 400, 500]})).unwrap()["id"].clone(); + s.execute("object.fill", &json!({"swatch": "Silver", "ids": [id]})).unwrap(); + assert!(sheet(&s).ink("Silver").is_some()); + s.execute("ink.options", &json!({"ink": "Gold", "alias": "Silver"})).unwrap(); + let sheet = sheet(&s); + assert!(sheet.ink("Gold").is_none(), "{:?}", sheet.inks()); + assert!(tint(&sheet, "Silver", 122.0, 122.0) > 250, "Gold's square is on the Silver plate"); + assert!(tint(&sheet, "Silver", 350.0, 450.0) > 250); + } + + #[test] + fn a_saved_document_with_ink_settings_loads_and_its_preview_applies_them() { + let s = document(); + let mut v = serde_json::to_value(&*s.doc().unwrap().doc).unwrap(); + // Ink Manager settings as documents save them. + v["inks"] = json!({"allToProcess": false, "toProcess": ["Gold"], "aliases": []}); + let doc: Document = serde_json::from_value(v).unwrap(); + assert_eq!(doc.inks.to_process, ["Gold"]); + let sheet = separate(&doc, &s.cache, 0, 36.0, &Print::default()).unwrap(); + assert!(sheet.ink("Gold").is_none(), "Gold prints as process: {:?}", sheet.inks()); + assert!(tint(&sheet, "Yellow", 122.0, 122.0) > 150, "Gold's yellow"); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b1792485d..6347b605f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,15 @@ use serde_json::json; use super::*; +mod autosize; +mod file_guard; +mod idml; +mod ime; +mod linked_paths; +mod params; +mod roots; +mod table; + fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 2})).unwrap(); diff --git a/crates/engine/src/tests_autosize.rs b/crates/engine/src/tests/autosize.rs similarity index 100% rename from crates/engine/src/tests_autosize.rs rename to crates/engine/src/tests/autosize.rs diff --git a/crates/engine/src/tests_file_guard.rs b/crates/engine/src/tests/file_guard.rs similarity index 88% rename from crates/engine/src/tests_file_guard.rs rename to crates/engine/src/tests/file_guard.rs index 9f2af3c32..f62e5c338 100644 --- a/crates/engine/src/tests_file_guard.rs +++ b/crates/engine/src/tests/file_guard.rs @@ -31,9 +31,13 @@ const ALLOWED: &[(&str, &str)] = &[ "`OutputRoot` checks the folder against the write root before it creates anything and then only writes inside it. Its reads are of the font files that draw the document's text (installed fonts and the `Document Fonts` folder), never of a path a command names", ), ( - "fonts/src/fontdb.rs", + "fonts/src/fontdb/system.rs", "the installed-font scan reads the platform's fixed font folders (`system_font_dirs`: no command parameter or document field names one), and `load_document_fonts` asks the caller's `allow` check about its folder and each font file before opening either", ), + ( + "ui-egui/src/app/file_dialogs.rs", + "the host's own file dialogs: it only tests whether a path the user picked in the system dialog exists (the replace-file question, the folder a Save dialog opens in), then re-runs the command with that path, which the engine checks as usual", + ), ( "mcp/src/tools.rs", "`screenshot` reads back the PNG the connected app just wrote. Connected mode cannot be combined with the roots (the CLI refuses the pair), and the app's own engine checks the path it writes", @@ -68,8 +72,8 @@ fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { let mut i = 0; while i < lines.len() { let attr = lines[i].trim(); - let module = lines.get(i + 1).map(|l| l.trim_start().trim_start_matches("pub ")); - if attr.starts_with("#[cfg(") && attr.contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { + let module = lines.get(i + 1).map(|l| without_visibility(l.trim_start())); + if attr.starts_with("#[cfg(") && attr.replace("not(test)", "").contains("test") && module.is_some_and(|m| m.starts_with("mod ")) { let header = lines[i + 1]; i += 2; if header.trim_end().ends_with('{') { @@ -88,6 +92,14 @@ fn code_outside_tests(text: &str) -> Vec<(usize, &str)> { code } +/// `item` without a leading `pub`, `pub(crate)`, `pub(super)` or `pub(in …)`: a test module +/// that sibling modules share is still a test module. +fn without_visibility(item: &str) -> &str { + let Some(rest) = item.strip_prefix("pub") else { return item }; + let rest = if rest.starts_with('(') { rest.find(')').map_or(rest, |close| &rest[close + 1..]) } else { rest }; + if rest.starts_with(char::is_whitespace) { rest.trim_start() } else { item } +} + /// `line` without its `//` comment (a `//` inside a string or char is rare enough in this code /// to leave alone: it only hides the rest of that line from the scan). fn without_comment(line: &str) -> &str { @@ -131,7 +143,7 @@ fn imports_fs(text: &str) -> Vec { for (n, line) in code_outside_tests(text) { let line = without_comment(line); if statement.is_empty() { - if !line.trim_start().starts_with("use ") && !line.trim_start().starts_with("pub use ") { + if !without_visibility(line.trim_start()).starts_with("use ") { continue; } start = n; @@ -247,4 +259,6 @@ fn the_scan_sees_every_spelling() { } assert!(imports_fs("use std::io::Read;\nuse std::path::Path;").is_empty()); assert!(imports_fs("#[cfg(test)]\nmod tests {\n use std::fs;\n}\n").is_empty(), "test modules are skipped"); + assert!(imports_fs("#[cfg(test)]\npub(super) mod shared {\n use std::fs;\n}\n").is_empty(), "shared test modules are skipped"); + assert!(!imports_fs("#[cfg(not(test))]\npub(crate) mod real {\n use std::fs;\n}\n").is_empty(), "cfg(not(test)) modules are scanned"); } diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests/idml.rs similarity index 98% rename from crates/engine/src/tests_idml.rs rename to crates/engine/src/tests/idml.rs index 3a49168be..e8766d091 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests/idml.rs @@ -30,6 +30,13 @@ fn magazine_round_trips_through_idml() { assert_eq!(back.page_count(), d.page_count()); assert_eq!(back.spreads.len(), d.spreads.len()); assert_eq!(back.parents.len(), d.parents.len()); + assert_magazine_stories(&d, &back); + assert_magazine_styles(&d, &back); + assert_magazine_geometry(&d, &back); + assert_magazine_assets(&d, &back); +} + +fn assert_magazine_stories(d: &Document, back: &Document) { // Stories: same text in the same order, same styles applied. let ta: Vec<&str> = d.stories.values().map(|s| s.text.as_str()).collect(); let tb: Vec<&str> = back.stories.values().map(|s| s.text.as_str()).collect(); @@ -48,6 +55,9 @@ fn magazine_round_trips_through_idml() { assert_eq!(pa.para, pb.para); } } +} + +fn assert_magazine_styles(d: &Document, back: &Document) { // Styles and swatches by name. for s in &d.styles.paragraph { let b = back.styles.para(&s.name).unwrap_or_else(|| panic!("paragraph style {}", s.name)); @@ -64,6 +74,9 @@ fn magazine_round_trips_through_idml() { let b = back.swatch(&s.name).unwrap_or_else(|| panic!("swatch {}", s.name)); assert_eq!(b.value, s.value, "{}", s.name); } +} + +fn assert_magazine_geometry(d: &Document, back: &Document) { // Frame geometry (document and parent spreads), pages. for (sa, sb) in [(&d.spreads, &back.spreads), (&d.parents, &back.parents)] { let (ia, ib) = (items(sa), items(sb)); @@ -78,8 +91,11 @@ fn magazine_round_trips_through_idml() { } } // Threads: the same frames in the same order. - assert_eq!(positions(&d), positions(&back)); + assert_eq!(positions(d), positions(back)); assert!(back.stories.values().any(|s| s.frames.len() == 2)); +} + +fn assert_magazine_assets(d: &Document, back: &Document) { // Images are embedded. assert_eq!(back.assets.len(), d.assets.len()); for (a, b) in d.assets.values().zip(back.assets.values()) { @@ -87,7 +103,7 @@ fn magazine_round_trips_through_idml() { assert_eq!(a.pixels, b.pixels); } // A second round trip is stable. - let again = designcraft_idml::import_idml(&designcraft_idml::export_idml(&back)).unwrap(); + let again = designcraft_idml::import_idml(&designcraft_idml::export_idml(back)).unwrap(); assert_eq!(items(&again.spreads).len(), items(&back.spreads).len()); } diff --git a/crates/engine/src/tests_ime.rs b/crates/engine/src/tests/ime.rs similarity index 74% rename from crates/engine/src/tests_ime.rs rename to crates/engine/src/tests/ime.rs index 266470914..222142c80 100644 --- a/crates/engine/src/tests_ime.rs +++ b/crates/engine/src/tests/ime.rs @@ -4,7 +4,7 @@ use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use serde_json::json; -use super::*; +use super::{Arc, Session, ViewInfo, ime_delete_ranges}; fn session() -> Session { let mut s = Session::new(); @@ -30,6 +30,49 @@ fn steps(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } +/// Byte counts round inward to whole characters, cap at the text bounds, and scan from the requested count rather than the story start. +#[test] +fn ime_byte_deletion_rounds_inward_from_the_requested_count() { + // a: 0..1, é: 1..3, 😀: 3..7, b: 7..8. + let text = "aé😀b"; + assert_eq!(ime_delete_ranges(text, 7, 7, 4, 0, true), Some((3..7, 7..7)), "the whole emoji"); + assert_eq!(ime_delete_ranges(text, 7, 7, 6, 0, true), Some((1..7, 7..7)), "é and the emoji"); + assert_eq!(ime_delete_ranges(text, 7, 7, 3, 0, true), Some((7..7, 7..7)), "a count starting inside the emoji leaves it"); + assert_eq!(ime_delete_ranges(text, 7, 7, 100, 0, true), Some((0..7, 7..7)), "more bytes than the text takes it all"); + // The scan starts at the requested count, not at the start of the story. + let long = format!("{}é", "x".repeat(20_000)); + let end = long.len(); + assert_eq!(ime_delete_ranges(&long, end, end, 3, 0, true), Some((end - 3..end, end..end)), "x and é"); + assert_eq!(ime_delete_ranges(&long, end, end, 2, 0, true), Some((end - 2..end, end..end)), "the whole é"); + assert_eq!(ime_delete_ranges(&long, end, end, 1, 0, true), Some((end..end, end..end)), "one byte inside é leaves it"); +} + +/// Reports exact before/after byte ranges, preserves and remaps the selection direction; no-op deletes report `None`. +#[test] +fn ime_delete_reports_the_ranges_it_applied() { + let mut s = session(); + let sid = typing_into(&mut s, "aé😀b"); + s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 3})).unwrap(); + let m = s.ime_delete_surrounding(2, 4, true).unwrap().expect("the delete changed the text"); + assert_eq!(m.deleted_before, 0..1, "the byte before the selection"); + assert_eq!(m.deleted_after, 3..7, "the emoji after it"); + assert_eq!((m.anchor_before, m.focus_before), (1, 3)); + assert_eq!((m.anchor_after, m.focus_after), (0, 2), "the selection stays selected, shifted left"); + assert_eq!((m.text_len_before, m.text_len_after), (8, 3)); + assert!(m.inserted.is_empty()); + assert_eq!(text(&mut s, sid), "éb"); + // A request that removes nothing is reported as no mutation at all. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.ime_delete_surrounding(0, 0, false).unwrap().is_none()); + // A commit reports the text it typed and replaces the selection. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap(); + let m = s.ime_commit("日", ViewInfo::default()).unwrap().expect("the commit changed the text"); + assert_eq!(m.inserted, "日"); + assert!(m.deleted_before.is_empty() && m.deleted_after.is_empty()); + assert_eq!((m.anchor_after, m.focus_after), (3, 3)); + assert_eq!(text(&mut s, sid), "日b"); +} + #[test] fn ime_composition_shows_in_place_and_commits_as_one_step() { // Romaji → kana → conversion → commit, as the macOS Japanese IME sends it. diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests/linked_paths.rs similarity index 100% rename from crates/engine/src/tests_linked_paths.rs rename to crates/engine/src/tests/linked_paths.rs diff --git a/crates/engine/src/tests_params.rs b/crates/engine/src/tests/params.rs similarity index 100% rename from crates/engine/src/tests_params.rs rename to crates/engine/src/tests/params.rs diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests/roots.rs similarity index 99% rename from crates/engine/src/tests_roots.rs rename to crates/engine/src/tests/roots.rs index 39ab5e57d..0f9b9654e 100644 --- a/crates/engine/src/tests_roots.rs +++ b/crates/engine/src/tests/roots.rs @@ -23,7 +23,7 @@ fn folders(name: &str) -> (PathBuf, Arc) { for d in ["in", "out", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); } - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests/table.rs similarity index 89% rename from crates/engine/src/tests_table.rs rename to crates/engine/src/tests/table.rs index 2fc4fd338..ecf0b9bbe 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests/table.rs @@ -1,6 +1,6 @@ use serde_json::json; -use super::*; +use super::{Session, doc}; fn session_with_frame() -> (Session, u64, u64) { let mut s = Session::new(); @@ -911,100 +911,115 @@ fn split_selected_row_or_column_splits_both_cells() { fn split_selected_block_and_merged_cells_preserves_geometry_and_history() { for horizontal in [true, false] { for merged in [false, true] { - let (mut s, sid, _) = session_with_frame(); - let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap(); - s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap(); - s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap(); - s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap(); - s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap(); - for r in 0..4 { - for c in 0..4 { - s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap(); - } - } - let range = if !merged { - doc::CellRange::new(1, 1, 2, 2) - } else if horizontal { - doc::CellRange::new(0, 0, 2, 1) - } else { - doc::CellRange::new(0, 0, 1, 2) - }; - let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]}); - s.execute("table.select", &selection).unwrap(); - s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap(); - if merged { - let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) }; - s.execute("table.merge", &merge).unwrap(); - s.execute("table.select", &selection).unwrap(); - } - let before_doc = s.doc().unwrap().doc.clone(); - let before_selection = s.doc().unwrap().selection.clone(); - let before = table_for(&s, sid, tid).clone(); - let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None); - let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); - let undo_len = s.doc().unwrap().history.undo.len(); - let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; - s.execute(command, &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry"); - let after_doc = s.doc().unwrap().doc.clone(); - let after_selection = s.doc().unwrap().selection.clone(); - let after = table_for(&s, sid, tid).clone(); - let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None); - let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); - assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table"); - assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once"); - for placed in &before_fragment.cells { - let original = before.cell(placed.row, placed.col).unwrap(); - let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect(); - assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source); - let surviving = matches[0]; - let cell = after.cell(surviving.row, surviving.col).unwrap(); - let mut normalized = cell.clone(); - normalized.row_span = original.row_span; - normalized.col_span = original.col_span; - assert_eq!(&normalized, original, "existing cell text and formatting are preserved"); - if !range.contains(placed.row, placed.col) { - assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged"); - continue; - } - let second_addr = if horizontal { - (surviving.row + cell.row_span as usize, surviving.col) - } else { - (surviving.row, surviving.col + cell.col_span as usize) - }; - let second = after.cell(second_addr.0, second_addr.1).unwrap(); - assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr); - assert!(second.text.is_empty()); - assert_eq!(second.style, original.style); - assert_eq!(second.insets, original.insets); - assert_eq!(second.fill, original.fill); - assert_eq!(second.fill_tint, original.fill_tint); - let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect; - assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell"); - if horizontal { - assert_eq!(surviving.rect.y1, second_rect.y0); - } else { - assert_eq!(surviving.rect.x1, second_rect.x0); - } - } - let text = s.doc().unwrap().selection.text.unwrap(); - let address = text.cell.unwrap(); - assert_eq!(after.owner(address.row, address.col), (address.row, address.col)); - assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len()); - let cells = s.doc().unwrap().selection.cells.unwrap(); - assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols()); - check(&s); - s.execute("edit.undo", &json!({})).unwrap(); - assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc)); - assert_eq!(s.doc().unwrap().selection, before_selection); - assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); - s.execute("edit.redo", &json!({})).unwrap(); - assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc)); - assert_eq!(s.doc().unwrap().selection, after_selection); - let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc).unwrap(); - let reopened = crate::cmd::file_from(&bytes).unwrap(); - reopened.check().unwrap(); - assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); + assert_split_case(horizontal, merged); + } + } +} + +fn assert_split_case(horizontal: bool, merged: bool) { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 4, "cols": 4, "width": 240})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.setRowHeight", &json!({"table": tid, "height": 60, "mode": "exactly"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Neighbor", "fill": "[Paper]", "insets": 3})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Chosen", "fill": "[Black]", "tint": 0.15, "insets": 2})).unwrap(); + s.execute("style.cell.apply", &json!({"table": tid, "name": "Neighbor"})).unwrap(); + for r in 0..4 { + for c in 0..4 { + s.execute("table.setCell", &json!({"row": r, "col": c, "text": format!("{r},{c}")})).unwrap(); + } + } + let range = if !merged { + doc::CellRange::new(1, 1, 2, 2) + } else if horizontal { + doc::CellRange::new(0, 0, 2, 1) + } else { + doc::CellRange::new(0, 0, 1, 2) + }; + let selection = json!({"rows": [range.r0, range.r1], "cols": [range.c0, range.c1]}); + s.execute("table.select", &selection).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Chosen"})).unwrap(); + if merged { + let merge = if horizontal { json!({"rows": [0, 2], "cols": [1, 1]}) } else { json!({"rows": [1, 1], "cols": [0, 2]}) }; + s.execute("table.merge", &merge).unwrap(); + s.execute("table.select", &selection).unwrap(); + } + let before_doc = s.doc().unwrap().doc.clone(); + let before_selection = s.doc().unwrap().selection.clone(); + let before = table_for(&s, sid, tid).clone(); + let before_layout = s.cache.get(&before_doc, doc::StoryId(sid), None); + let before_fragment = before_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + let undo_len = s.doc().unwrap().history.undo.len(); + let command = if horizontal { "table.splitHorizontally" } else { "table.splitVertically" }; + s.execute(command, &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len + 1, "one split command creates one undo entry"); + let after_doc = s.doc().unwrap().doc.clone(); + let after_selection = s.doc().unwrap().selection.clone(); + let after = table_for(&s, sid, tid).clone(); + let after_layout = s.cache.get(&after_doc, doc::StoryId(sid), None); + let after_fragment = after_layout.frames.iter().flat_map(|f| &f.tables).find(|t| t.table == tid).unwrap(); + assert_eq!(after_fragment.rect, before_fragment.rect, "splitting does not resize the table"); + assert_eq!(after_fragment.cells.len(), before_fragment.cells.len() + 4, "four original owners split once"); + assert_split_cells(&before, &after, before_fragment, after_fragment, range, horizontal); + let text = s.doc().unwrap().selection.text.unwrap(); + let address = text.cell.unwrap(); + assert_eq!(after.owner(address.row, address.col), (address.row, address.col)); + assert!(text.focus <= after.cell(address.row, address.col).unwrap().text.len()); + let cells = s.doc().unwrap().selection.cells.unwrap(); + assert!(cells.range.r1 < after.nrows() && cells.range.c1 < after.ncols()); + check(&s); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before_doc)); + assert_eq!(s.doc().unwrap().selection, before_selection); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &after_doc)); + assert_eq!(s.doc().unwrap().selection, after_selection); + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc).unwrap(); + let reopened = crate::cmd::file_from(&bytes).unwrap(); + reopened.check().unwrap(); + assert_eq!(reopened.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &after); +} + +fn assert_split_cells( + before: &designcraft_doc::Table, + after: &designcraft_doc::Table, + before_fragment: &designcraft_compose::TableFrag, + after_fragment: &designcraft_compose::TableFrag, + range: doc::CellRange, + horizontal: bool, +) { + for placed in &before_fragment.cells { + let original = before.cell(placed.row, placed.col).unwrap(); + let matches: Vec<_> = after_fragment.cells.iter().filter(|c| c.source == placed.source).collect(); + assert_eq!(matches.len(), 1, "original cell text survives once: {}", placed.source); + let surviving = matches[0]; + let cell = after.cell(surviving.row, surviving.col).unwrap(); + let mut normalized = cell.clone(); + normalized.row_span = original.row_span; + normalized.col_span = original.col_span; + assert_eq!(&normalized, original, "existing cell text and formatting are preserved"); + if !range.contains(placed.row, placed.col) { + assert_eq!(surviving.rect, placed.rect, "unselected neighbor geometry is unchanged"); + continue; + } + let second_addr = if horizontal { + (surviving.row + cell.row_span as usize, surviving.col) + } else { + (surviving.row, surviving.col + cell.col_span as usize) + }; + let second = after.cell(second_addr.0, second_addr.1).unwrap(); + assert_eq!(after.owner(second_addr.0, second_addr.1), second_addr); + assert!(second.text.is_empty()); + assert_eq!(second.style, original.style); + assert_eq!(second.insets, original.insets); + assert_eq!(second.fill, original.fill); + assert_eq!(second.fill_tint, original.fill_tint); + let second_rect = after_fragment.cell(second_addr.0, second_addr.1).unwrap().rect; + assert_eq!(surviving.rect.union(second_rect), placed.rect, "the halves exactly cover the original cell"); + if horizontal { + assert_eq!(surviving.rect.y1, second_rect.y0); + } else { + assert_eq!(surviving.rect.x1, second_rect.x0); } } } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 65d069bac..c982cade7 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1,12 +1,14 @@ //! Tool hosting: the active tool receives pointer/key events and its actions are executed here. +use std::ops::Range; use std::sync::Arc; +use designcraft_doc::{CellAddr, StoryId}; use designcraft_geom::Unit; pub use designcraft_tools::SnapView; use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolContext, ToolKey}; use serde::Serialize; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{EngineError, HistoryEntry, Interaction, Result, Session, record_undo}; @@ -346,3 +348,307 @@ impl Session { } } } + +/// The committed text around the Type tool's IME caret. +/// +/// While an IME composition is active the marked text is only an interaction preview, so this +/// borrows the interaction's committed snapshot and selection instead of the previewed document. +/// `anchor` and `focus` are UTF-8 byte offsets into `text`; `focus` is the primary caret end. +#[derive(Clone, Copy, Debug)] +pub struct ImeTextContext<'a> { + /// Stable document identity (a new document gets a new value). + pub document_uid: u64, + /// Document-state identity: changes when the state is recreated even if `document_uid` is + /// preserved (File › Revert). + pub document_epoch: u64, + /// Story being edited, or the story that owns the edited table cell. + pub story: StoryId, + /// Edited table cell, when the caret is in one. + pub cell: Option, + /// Committed logical story text, without the current IME pre-edit. + pub text: &'a str, + /// Selection anchor (the end that stays put under Shift-arrow selection). + pub anchor: usize, + /// Primary selection end (the caret). + pub focus: usize, +} + +/// What an accepted IME mutation applied to the committed story text. +/// +/// The engine reports this from the whole story, not from the bounded outgoing IME window, so a +/// successful mutation can be described exactly even when the deleted text lies outside that +/// window. `deleted_before` and `deleted_after` are byte ranges of the text before the mutation; +/// `inserted` is the text the mutation typed at the selection. Invalid or unreportable mutations +/// return `None` rather than a guessed description. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeMutation { + /// Stable document identity of the mutation. + pub document_uid: u64, + /// Document-state identity (see [`ImeTextContext::document_epoch`]). + pub document_epoch: u64, + /// Story (or the story owning the edited cell) that changed. + pub story: StoryId, + /// Edited table cell, when the caret was in one. + pub cell: Option, + /// Committed selection before and after the mutation (UTF-8 byte offsets). + pub anchor_before: usize, + pub focus_before: usize, + pub anchor_after: usize, + pub focus_after: usize, + /// Committed story text length before and after the mutation. + pub text_len_before: usize, + pub text_len_after: usize, + /// Text removed before and after the selection (empty when nothing was removed). + pub deleted_before: Range, + pub deleted_after: Range, + /// Text typed at the selection, after the commit normalization. + pub inserted: String, +} + +/// The identity, committed selection and text length at the IME caret. +#[derive(Clone, Copy, Debug)] +struct ImeState { + document_uid: u64, + document_epoch: u64, + story: StoryId, + cell: Option, + anchor: usize, + focus: usize, + text_len: usize, +} + +/// The ranges a successful `text.deleteSurrounding` reports it removed. +fn ime_ranges_from_command(r: &Value) -> Option<(Range, Range)> { + let range = |key: &str| { + let arr = r.get(key)?.as_array()?; + let start = usize::try_from(arr.first()?.as_u64()?).ok()?; + let end = usize::try_from(arr.get(1)?.as_u64()?).ok()?; + (start <= end).then_some(start..end) + }; + Some((range("deletedBefore")?, range("deletedAfter")?)) +} + +/// The byte ranges an IME delete-surrounding request removes around a selection. +/// +/// The returned pair is `(before_selection, after_selection)`. Counts exclude the selection +/// itself and the current pre-edit. Character counts are Unicode scalar values. Byte counts +/// round inward: a count that ends inside a character leaves that whole character. Invalid or +/// non-boundary selection offsets return `None` rather than guessed ranges. +pub fn ime_delete_ranges(text: &str, anchor: usize, focus: usize, before: usize, after: usize, bytes: bool) -> Option<(Range, Range)> { + let (min, max) = ime_selection_bounds(text, anchor, focus)?; + Some(if bytes { ime_byte_delete_ranges(text, min, max, before, after) } else { ime_character_delete_ranges(text, min, max, before, after) }) +} + +/// Validate the committed selection and return its lower and upper UTF-8 byte offsets. +fn ime_selection_bounds(text: &str, anchor: usize, focus: usize) -> Option<(usize, usize)> { + if anchor > text.len() || focus > text.len() || !text.is_char_boundary(anchor) || !text.is_char_boundary(focus) { + return None; + } + Some((anchor.min(focus), anchor.max(focus))) +} + +/// Byte-counted ranges around the selection, rounded inward to character boundaries. +fn ime_byte_delete_ranges(text: &str, min: usize, max: usize, before: usize, after: usize) -> (Range, Range) { + // Start at the requested byte offset and step at most three bytes to the next character + // boundary: the scan is bounded by the count, not by the caret's offset in the story. + let from = min.saturating_sub(before).min(min); + let mut before_start = from; + while before_start < min && !text.is_char_boundary(before_start) { + before_start += 1; + } + let target = max.saturating_add(after).min(text.len()); + let mut after_end = max; + for (i, c) in text[max..].char_indices() { + let end = max + i + c.len_utf8(); + if end <= target { + after_end = end; + } else { + break; + } + } + (before_start..min, max..after_end) +} + +/// Character-counted ranges around the selection, using Unicode scalar values. +fn ime_character_delete_ranges(text: &str, min: usize, max: usize, before: usize, after: usize) -> (Range, Range) { + let before_start = if before == 0 { + min + } else { + let mut seen = 0usize; + let mut start = 0usize; + for (i, _) in text[..min].char_indices().rev() { + seen += 1; + start = i; + if seen == before { + break; + } + } + if seen < before { 0 } else { start } + }; + let mut after_end = max; + if after > 0 { + let mut seen = 0usize; + for (i, c) in text[max..].char_indices() { + seen += 1; + after_end = max + i + c.len_utf8(); + if seen == after { + break; + } + } + } + (before_start..min, max..after_end) +} + +impl Session { + /// The committed text and selection at the Type tool's IME caret. + /// + /// Returns `None` without an insertion point, without a committed story, or while a + /// composition has no interaction snapshot to describe its committed text. The query never + /// copies the story. + pub fn ime_text_context(&self) -> Option> { + let st = self.active()?; + let (doc, ts) = if self.tool.composing() { + let it = st.interaction.as_ref()?; + (it.doc.as_ref(), it.selection.text?) + } else { + (st.doc.as_ref(), st.selection.text?) + }; + let story = doc.text_story(ts.story, ts.cell)?; + if ts.anchor > story.text.len() + || ts.focus > story.text.len() + || !story.text.is_char_boundary(ts.anchor) + || !story.text.is_char_boundary(ts.focus) + { + // A stale or corrupt insertion point: report unavailable rather than inventing offsets. + return None; + } + Some(ImeTextContext { + document_uid: st.uid, + document_epoch: st.identity_epoch, + story: ts.story, + cell: ts.cell, + text: &story.text, + anchor: ts.anchor, + focus: ts.focus, + }) + } + + /// Delete the text an IME asked to remove around the selection, as one undo step. + /// + /// The mutation goes through the `text.deleteSurrounding` command, so it restores a Data Merge + /// preview, records undo, auto-sizes, journals and is panic-guarded like any other edit. + /// The selection itself and any current pre-edit are excluded. Character counts are scalar + /// values; byte counts round inward to whole characters. A selection keeps its direction and + /// is remapped past the deleted text. + pub fn ime_delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result> { + if !self.ime_deletion_changes_text(before, after, bytes)? { + return Ok(None); + } + let Some(pre) = self.ime_state() else { return Ok(None) }; + self.end_preedit_without_typing()?; + let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))?; + Ok(self.ime_mutation_from_command(pre, &r, String::new())) + } + + /// The IME committed `text`: one `text.insert`, reported as the mutation it applied. + pub fn ime_commit(&mut self, text: &str, view: ViewInfo) -> Result> { + let Some(pre) = self.ime_state() else { return self.tool_ime_commit(text, view).map(|()| None) }; + self.tool_ime_commit(text, view)?; + let inserted = designcraft_tools::ime_commit_text(text); + // A commit replaces the selection: the removed ranges are empty at its two ends. + let (start, end) = (pre.anchor.min(pre.focus), pre.anchor.max(pre.focus)); + Ok(self.ime_mutation_from_states(pre, start..start, end..end, inserted)) + } + + /// Delete surrounding text and insert the IME commit in one undo step. + /// + /// This is the normal delete-surrounding-then-commit batch: both mutations are one + /// `text.deleteSurrounding` command, so one Undo restores the state before the batch. + pub fn ime_commit_after_delete(&mut self, text: &str, view: ViewInfo, before: usize, after: usize, bytes: bool) -> Result> { + if !self.ime_deletion_changes_text(before, after, bytes)? { + return self.ime_commit(text, view); + } + let Some(pre) = self.ime_state() else { return Ok(None) }; + self.end_preedit_without_typing()?; + let inserted = designcraft_tools::ime_commit_text(text); + let r = self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes, "commit": inserted.clone()}))?; + Ok(self.ime_mutation_from_command(pre, &r, inserted)) + } + + /// The identity, committed selection and text length of the IME caret's story. + fn ime_state(&self) -> Option { + let context = self.ime_text_context()?; + Some(ImeState { + document_uid: context.document_uid, + document_epoch: context.document_epoch, + story: context.story, + cell: context.cell, + anchor: context.anchor, + focus: context.focus, + text_len: context.text.len(), + }) + } + + /// Report the mutation the delete command applied, from its ranges and the states around it. + fn ime_mutation_from_command(&self, before: ImeState, r: &Value, inserted: String) -> Option { + let (deleted_before, deleted_after) = ime_ranges_from_command(r)?; + self.ime_mutation_from_states(before, deleted_before, deleted_after, inserted) + } + + /// Return a mutation only when the document uid/epoch, story and cell still match; otherwise report no mutation. + fn ime_mutation_from_states( + &self, + before: ImeState, + deleted_before: Range, + deleted_after: Range, + inserted: String, + ) -> Option { + let after = self.ime_state()?; + // The mutation must have stayed in the story it began in. + if after.document_uid != before.document_uid + || after.document_epoch != before.document_epoch + || after.story != before.story + || after.cell != before.cell + { + return None; + } + Some(ImeMutation { + document_uid: before.document_uid, + document_epoch: before.document_epoch, + story: before.story, + cell: before.cell, + anchor_before: before.anchor, + focus_before: before.focus, + anchor_after: after.anchor, + focus_after: after.focus, + text_len_before: before.text_len, + text_len_after: after.text_len, + deleted_before, + deleted_after, + inserted, + }) + } + + /// Clear an active pre-edit without inserting text; if no interaction snapshot exists, reset the tool. + fn end_preedit_without_typing(&mut self) -> Result<()> { + if !self.tool.composing() { + return Ok(()); + } + match self.with_ctx(ViewInfo::default(), |s, cx| s.tool.ime_preedit(cx, "", None)) { + Some(actions) => self.run_actions(actions), + None => { + self.tool = designcraft_tools::create(self.tool.id()); + Ok(()) + } + } + } + + /// Report false for no IME context or no-op ranges, and error when the selection is not on character boundaries. + fn ime_deletion_changes_text(&self, before: usize, after: usize, bytes: bool) -> Result { + let Some(context) = self.ime_text_context() else { return Ok(false) }; + let Some((before_range, after_range)) = ime_delete_ranges(context.text, context.anchor, context.focus, before, after, bytes) else { + return Err(EngineError::Other("the IME text selection is not on character boundaries".into())); + }; + Ok(!before_range.is_empty() || !after_range.is_empty()) + } +} diff --git a/crates/engine/tests/graphic_layer_options.rs b/crates/engine/tests/graphic_layer_options.rs new file mode 100644 index 000000000..44ddb8994 --- /dev/null +++ b/crates/engine/tests/graphic_layer_options.rs @@ -0,0 +1,128 @@ +//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported. +use std::io::{Cursor, Read, Write}; + +use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING}; +use designcraft_engine::{DocState, Session}; +use serde_json::json; + +fn fixture() -> Vec { + let xml = r#" + + + + + + + + + "#; + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(xml.as_bytes()).unwrap(); + z.finish().unwrap().into_inner() +} + +#[test] +fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() { + let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap(); + assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING))); + let doc = imported.document; + let options = |d: &designcraft_doc::Document| { + d.spreads[0] + .items + .iter() + .filter_map(|i| match &i.content { + Content::Graphic(g) => g.layer_options.clone(), + _ => None, + }) + .collect::>() + }; + let expected = options(&doc); + let saved = designcraft_format::save(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings"); + let reopened = designcraft_format::load(&saved).unwrap(); + assert_eq!(options(&reopened), expected); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers")); + let pdf = s.execute("file.exportPdf", &json!({})).unwrap(); + assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING)); +} + +#[test] +fn graphic_layer_options_in_cells_require_the_new_native_reader() { + let mut doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let graphic = doc.spreads[0] + .items + .iter() + .find_map(|i| match &i.content { + Content::Graphic(g) => Some(g.clone()), + _ => None, + }) + .unwrap(); + // Keep the original spread graphic too: its warning must not hide the cell warning. + let layer = doc.default_layer(); + let (_, story) = doc + .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) + .unwrap(); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].graphic = Some(graphic); + doc.story_mut(story).unwrap().insert_table(0, table); + assert!(doc.has_graphic_layer_options()); + let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap(); + assert!(reopened.has_graphic_layer_visibility_overrides()); + let mut s = Session::new(); + s.add_document(DocState::new(reopened, None)); + let preflight = s.execute("preflight.run", &json!({})).unwrap(); + let warnings: Vec<_> = preflight["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedGraphicLayers").collect(); + assert!(warnings.iter().any(|i| i["item"].is_null()), "the cell needs an independent document-level warning"); + assert!(warnings.iter().any(|i| !i["item"].is_null()), "the spread retains its item warning"); +} + +#[test] +fn a_graphic_layer_cell_alone_still_requires_the_new_native_reader() { + // The cell is the only thing carrying the settings: a save that only looked at spread items + // would choose format 3 and drop them. + let mut doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let graphic = doc.spreads[0] + .items + .iter() + .find_map(|i| match &i.content { + Content::Graphic(g) => Some(g.clone()), + _ => None, + }) + .unwrap(); + let layer = doc.default_layer(); + let (_, story) = doc + .add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()) + .unwrap(); + // Only the cell keeps them: the spread's own placed graphic goes away. + std::sync::Arc::make_mut(&mut doc.spreads[0]).items.retain(|i| i.graphic().is_none()); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0); + table.cells[0].graphic = Some(graphic); + doc.story_mut(story).unwrap().insert_table(0, table); + assert!(doc.has_graphic_layer_options(), "the cell alone carries the settings"); + let saved = designcraft_format::save(&doc).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap(); + let mut meta = String::new(); + z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap(); + assert_eq!(serde_json::from_str::(&meta).unwrap()["version"], 4, "a cell-only save needs the newer reader"); + assert!(designcraft_format::load(&saved).unwrap().has_graphic_layer_visibility_overrides()); +} + +#[test] +fn pdf_layer_warning_survives_soft_effects_and_flattening() { + for flatten in [false, true] { + let doc = designcraft_idml::import_idml(&fixture()).unwrap(); + let id = doc.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id.0; + let mut session = Session::new(); + session.add_document(DocState::new(doc, None)); + session.execute("object.feather", &json!({"ids":[id], "width":2})).unwrap(); + let out = session.execute("file.exportPdf", &json!({"flatten":flatten})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING), "{out}"); + } +} diff --git a/crates/engine/tests/it/pdf_backend.rs b/crates/engine/tests/it/pdf_backend.rs index 5344dc6f7..e68cbae81 100644 --- a/crates/engine/tests/it/pdf_backend.rs +++ b/crates/engine/tests/it/pdf_backend.rs @@ -118,11 +118,7 @@ fn exported(hidden: bool) -> Vec { session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Red"]})).unwrap(); } let report = session.execute("file.exportPdf", &json!({"compress": false})).unwrap(); - let bytes = base64_decode(report["base64"].as_str().unwrap()); - if hidden { - assert!(report["warnings"].to_string().contains("hidden layers")); - } - bytes + base64_decode(report["base64"].as_str().unwrap()) } #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] @@ -185,7 +181,8 @@ fn oversized_image_does_not_silently_disappear_from_layered_export() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let result = session.execute("file.exportPdf", &json!({})); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let result = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})); assert!(result.is_err(), "export must report the image omitted by the backend's resource limit"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "a failed export must leave the document unchanged"); let error = result.unwrap_err().to_string(); @@ -317,7 +314,8 @@ fn failed_content_decode_aborts_layered_export_without_changing_the_document() { .unwrap(); session.execute("object.layerOptions", &json!({"ids": [placed["id"]], "hidden": ["Blue"]})).unwrap(); let before = session.execute("file.serialize", &json!({})).unwrap(); - let error = session.execute("file.exportPdf", &json!({})).unwrap_err().to_string(); + // PDF/A-2b has no optional content: placed PDFs with hidden layers go out as images there. + let error = session.execute("file.exportPdf", &json!({"standard": "pdfa2b"})).unwrap_err().to_string(); assert!(error.contains("broken-content.pdf") && error.contains("content"), "{filter}: {error}"); assert_eq!(session.execute("file.serialize", &json!({})).unwrap(), before, "{filter}"); } diff --git a/crates/engine/tests/it/text_decorations.rs b/crates/engine/tests/it/text_decorations.rs index c49c1d2cb..205bc97fb 100644 --- a/crates/engine/tests/it/text_decorations.rs +++ b/crates/engine/tests/it/text_decorations.rs @@ -50,8 +50,8 @@ fn raster(d: &Document, threads: u16) -> Rendered { renderer.threads = threads; let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; - let first = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); - let cached = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + let first = renderer.render(d, &cache, &placed, (480, 480), Affine::scale(2.0), &options); + let cached = renderer.render(d, &cache, &placed, (480, 480), Affine::scale(2.0), &options); assert_eq!(first.pixels, cached.pixels, "decoration cache hits must retain paint order"); first } @@ -185,7 +185,7 @@ fn an_underline_has_no_seams_between_letters() { let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; // A fractional scale puts the letters' edges between pixels. let (w, h) = (543u32, 163u32); - let img = renderer.render(&d, &Cache::new(), &placed, w, h, Affine::scale(1.3579), &options); + let img = renderer.render(&d, &Cache::new(), &placed, (w, h), Affine::scale(1.3579), &options); let px = |x: u32, y: u32| { let i = ((y * w + x) * 4) as usize; [img.pixels[i], img.pixels[i + 1], img.pixels[i + 2], img.pixels[i + 3]] diff --git a/crates/engine/tests/pdf_optional_content.rs b/crates/engine/tests/pdf_optional_content.rs new file mode 100644 index 000000000..e96e34915 --- /dev/null +++ b/crates/engine/tests/pdf_optional_content.rs @@ -0,0 +1,238 @@ +#[path = "support/optional_content.rs"] +mod support; + +#[path = "support/optional_content_fallback.rs"] +mod fallback; + +#[path = "support/optional_content_xref.rs"] +mod xref; + +use designcraft_compose::Cache; +use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report}; +use support::{catalog, document, fixture, painted}; + +const DEFAULT: &str = "/BaseState/ON/OFF[6 3 R]"; +const USAGE_APPLICATIONS: &str = "/BaseState/ON/ON[5 0 R]/OFF[6 3 R]/AS[<><><>]"; + +#[test] +fn source_layer_dictionaries_are_readable() { + let pdf = hayro_syntax::Pdf::new(fixture(DEFAULT, "Hidden")).unwrap(); + for id in [(5, 0), (6, 3)] { + assert!(pdf.xref().get::>(hayro_syntax::object::ObjectIdentifier::new(id.0, id.1)).is_some(), "{id:?}"); + } +} + +fn exported(config: &str, standard: Standard) -> Vec { + let doc = document(fixture(config, "Hidden"), &[&[]]); + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }).unwrap(); + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + report.bytes +} + +#[test] +fn source_default_off_renders_before_export() { + let bytes = fixture(DEFAULT, "Hidden"); + + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_registers_default_off_layers() { + let bytes = exported(DEFAULT, Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); +} + +#[test] +fn pdfx_export_preserves_default_off_catalog() { + let bytes = exported(DEFAULT, Standard::PdfX4); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); +} + +#[test] +fn plain_export_paints_only_the_shown_layer() { + let bytes = exported(DEFAULT, Standard::None); + + painted(&bytes, &[true, false]); +} + +#[test] +fn pdfx_export_paints_only_the_shown_layer() { + let bytes = exported(DEFAULT, Standard::PdfX4); + + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_preserves_defaults_with_usage_applications() { + let bytes = exported(USAGE_APPLICATIONS, Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +#[test] +fn pdfx_export_preserves_defaults_with_usage_applications() { + let bytes = exported(USAGE_APPLICATIONS, Standard::PdfX4); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +#[test] +fn plain_export_draws_groups_without_catalog_properties() { + let doc = document(support::orphaned_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn pdfx_export_draws_groups_without_catalog_properties() { + let doc = document(support::orphaned_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn base_off_with_on_exception_is_preserved() { + let bytes = exported("/BaseState/OFF/ON[5 0 R]", Standard::None); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); +} + +#[test] +fn same_asset_placements_keep_opposing_layer_states() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[], &["Shown"]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Hidden".into(), true), ("Shown".into(), false), ("Shown".into(), true)]); + painted(&bytes, &[true, false, false, true]); +} + +#[test] +fn duplicate_names_with_distinct_generations_keep_default_states() { + let doc = document(fixture(DEFAULT, "Shown"), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Shown".into(), false), ("Shown".into(), true)]); + painted(&bytes, &[true, false]); +} + +#[test] +fn unresolved_default_off_reference_fails_export() { + let doc = document(fixture("/OFF[99 0 R]", "Hidden"), &[&[]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions::default()); + + assert!(result.is_err(), "an unreadable layer state must not print hidden artwork"); +} + +#[test] +fn unlayered_pdf_stays_vector_without_optional_content() { + let doc = document(support::unlayered_fixture(), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(!root.contains_key(b"OCProperties")); + assert_vector(&bytes); + painted(&bytes, &[true, false]); +} + +fn assert_vector(bytes: &[u8]) { + use hayro_syntax::object::{Name, Object}; + let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap(); + for object in pdf.objects() { + if let Object::Stream(stream) = object { + assert_ne!(stream.dict().get::>(b"Subtype").as_ref().map(AsRef::as_ref), Some(b"Image".as_slice())); + } + } +} + +#[test] +fn layer_options_command_preserves_vector_overrides_in_plain_and_pdfx() { + use designcraft_engine::{ + Session, + cmd::{base64_decode, base64_encode}, + }; + use serde_json::json; + for standard in ["none", "x4"] { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session + .execute("file.place", &json!({"base64": base64_encode(&fixture(DEFAULT, "Hidden")), "name": "layers.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let bytes = base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), true)]); + assert_vector(&bytes); + painted(&bytes, &[false, true]); + } +} + +#[test] +fn exported_optional_content_survives_replacement_and_book_merge() { + let first = exported(DEFAULT, Standard::None); + let doc = document(first.clone(), &[&[]]); + + let replaced = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + let merged = designcraft_pdf::merge_pdfs(&[(first, vec![(100.0, 100.0)])], None).unwrap(); + + for bytes in [replaced, merged] { + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); + } +} + +#[test] +fn booklet_export_preserves_optional_content() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]); + let options = designcraft_pdf::BookletOptions { kind: designcraft_pdf::BookletKind::TwoUpConsecutive, ..Default::default() }; + + let report = designcraft_pdf::export_booklet(&doc, &Cache::new(), &options).unwrap(); + + assert!(report.warnings.is_empty()); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + assert_vector(&report.bytes); + painted(&report.bytes, &[true, false, false, false]); +} + +#[test] +fn archival_export_still_rejects_unvalidated_placed_pdf() { + let doc = document(fixture(DEFAULT, "Hidden"), &[&[]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, ..Default::default() }).unwrap_err(); + + assert!(error.to_string().contains("EmbeddedPDF"), "{error}"); +} + +#[test] +fn source_xref_stream_preserves_layer_states() { + let doc = document(support::stream_fixture(DEFAULT), &[&[]]); + + let bytes = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); +} diff --git a/crates/engine/tests/support/optional_content.rs b/crates/engine/tests/support/optional_content.rs new file mode 100644 index 000000000..b3acb3511 --- /dev/null +++ b/crates/engine/tests/support/optional_content.rs @@ -0,0 +1,174 @@ +use std::sync::Arc; + +use designcraft_doc::{Asset, AssetId, Content, Document, Graphic, Item, ItemId, Shape, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect, shapes}; +use hayro_syntax::{ + Pdf, + object::{Array, Dict, Name, ObjectIdentifier}, +}; + +pub fn fixture(config: &str, second_name: &str) -> Vec { + layered_fixture(Some(config), second_name) +} + +pub fn orphaned_fixture() -> Vec { + layered_fixture(None, "Hidden") +} + +fn layered_fixture(config: Option<&str>, second_name: &str) -> Vec { + let properties = config.map(|config| format!("/OCProperties<>>>")).unwrap_or_default(); + properties_fixture(&properties, second_name) +} + +pub fn properties_fixture(properties: &str, second_name: &str) -> Vec { + grouped_fixture(properties, Some(second_name)) +} + +pub fn nameless_fixture(config: &str) -> Vec { + grouped_fixture(&format!("/OCProperties<>>>"), None) +} + +fn grouped_fixture(properties: &str, second_name: Option<&str>) -> Vec { + let content = "/OC /a BDC 1 0 0 0 k 0 0 50 100 re f EMC /OC /b BDC 0 1 0 0 k 50 0 50 100 re f EMC"; + let name = second_name.map(|name| format!("/Name({name})")).unwrap_or_default(); + let objects = [ + format!("<>"), + "<>".into(), + "<>>>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + "<>>>/Custom(keep \\(this\\) >>)>>".into(), + format!("<>>>/Custom<00ff80>>>"), + ]; + assemble(&objects, &[2, 0, 0, 0, 0, 3]) +} + +pub fn unlayered_fixture() -> Vec { + let content = "1 0 0 0 k 0 0 50 100 re f"; + assemble( + &[ + "<>".into(), + "<>".into(), + "<>/Contents 4 0 R>>".into(), + format!("<>\nstream\n{content}\nendstream", content.len()), + ], + &[2, 0, 0, 0], + ) +} + +pub fn stream_fixture(config: &str) -> Vec { + let mut bytes = fixture(config, "Hidden"); + let tail = std::str::from_utf8(&bytes).unwrap(); + let previous: usize = tail.rsplit("startxref").next().unwrap().split_whitespace().next().unwrap().parse().unwrap(); + let offset = bytes.len(); + let mut entry = vec![1]; + entry.extend_from_slice(&u32::try_from(offset).unwrap().to_be_bytes()); + entry.extend_from_slice(&0u16.to_be_bytes()); + bytes.extend_from_slice(format!("7 0 obj\n<>\nstream\n").as_bytes()); + bytes.extend_from_slice(&entry); + bytes.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{offset}\n%%EOF\n").as_bytes()); + bytes +} + +fn assemble(objects: &[String], generations: &[i32]) -> Vec { + let mut bytes = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (index, body) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} {} obj\n{body}\nendobj\n", index + 1, generations[index]).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f\r\n", objects.len() + 1).as_bytes()); + for (offset, generation) in offsets.iter().zip(generations) { + bytes.extend_from_slice(format!("{offset:010} {generation:05} n\r\n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +pub fn document(bytes: Vec, hidden_sets: &[&[&str]]) -> Document { + let width = 100.0 * f64::from(u32::try_from(hidden_sets.len()).unwrap()); + let mut doc = Document::new(&NewDocument { width, height: 100.0, facing_pages: false, ..Default::default() }); + let asset = AssetId(doc.alloc()); + doc.add_asset(Asset { + id: asset, + name: "layers.pdf".into(), + mime: "application/pdf".into(), + data: Arc::new(bytes), + link: None, + pixels: None, + page: 0, + pdf_crop: Default::default(), + pdf_box: None, + }); + for (index, hidden) in hidden_sets.iter().enumerate() { + let x = 100.0 * f64::from(u32::try_from(index).unwrap()); + let mut item = Item::new(ItemId(doc.alloc()), doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))); + item.xf = Affine::translate((x, 0.0)); + item.pdf_hidden_layers = hidden.iter().map(|name| (*name).to_owned()).collect(); + item.content = Content::Graphic(Graphic { + asset, + size: (100.0, 100.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + layer_options: None, + }); + doc.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + } + doc +} + +pub fn refs(dict: &Dict<'_>, key: &[u8]) -> Vec { + dict.get::>(key).unwrap().raw_iter().map(|value| value.as_obj_ref().unwrap().into()).collect() +} + +pub fn catalog(bytes: &[u8]) -> Vec<(String, bool)> { + let pdf = Pdf::new(bytes.to_vec()).unwrap(); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let properties = root.get::>(b"OCProperties").expect("export must register its embedded OCGs"); + let groups = refs(&properties, b"OCGs"); + let defaults = properties.get::>(b"D").unwrap(); + assert!(defaults.get::>(b"Name").is_some()); + assert_eq!(defaults.get::>(b"BaseState").unwrap().as_ref(), b"ON"); + assert!(!defaults.contains_key(b"AS")); + assert_eq!(refs(&defaults, b"Order"), groups); + let off = refs(&defaults, b"OFF"); + assert!(off.iter().all(|id| groups.contains(id))); + let mut unique = groups.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), groups.len()); + let mut result = Vec::new(); + for id in groups { + let group = pdf.xref().get::>(id).unwrap(); + assert!(group.contains_key(b"Usage")); + assert!(group.contains_key(b"Intent")); + assert!(group.contains_key(b"Custom")); + let custom = group.get::>(b"Custom").unwrap(); + assert!([b"keep (this) >>".as_slice(), &[0, 255, 128]].contains(&custom.as_bytes())); + let intent = group.get::>(b"Intent").unwrap(); + assert_eq!(intent.iter::>().map(|name| name.as_ref().to_vec()).collect::>(), [b"View".to_vec(), b"Design".to_vec()]); + let usage = group.get::>(b"Usage").unwrap().get::>(b"Print").unwrap(); + assert_eq!(usage.get::>(b"PrintState").unwrap().as_ref(), b"ON"); + let name = group.get::>(b"Name").unwrap(); + result.push((String::from_utf8(name.as_bytes().to_vec()).unwrap(), off.contains(&id))); + } + result.sort(); + result +} + +pub fn painted(bytes: &[u8], expected: &[bool]) { + let width = u32::try_from(expected.len()).unwrap() * 50; + let image = designcraft_render::render_pdf_page(bytes, 0, width.max(100)).unwrap(); + for (index, painted) in expected.iter().enumerate() { + let image_width = usize::from(image.width()); + let x = (2 * index + 1) * image_width / (2 * expected.len()); + let y = usize::from(image.height()) / 2; + let pixel = image.data()[y * image_width + x]; + assert_eq!(pixel.a > 200, *painted, "half {index}: {pixel:?}"); + } +} diff --git a/crates/engine/tests/support/optional_content_fallback.rs b/crates/engine/tests/support/optional_content_fallback.rs new file mode 100644 index 000000000..5a94bb7b1 --- /dev/null +++ b/crates/engine/tests/support/optional_content_fallback.rs @@ -0,0 +1,263 @@ +use super::support::{catalog, document, nameless_fixture, painted, properties_fixture}; +use designcraft_compose::Cache; +use designcraft_engine::{Session, cmd::base64_encode}; +use designcraft_pdf::{PdfOptions, Standard, export_pdf, export_pdf_with_report}; +use serde_json::json; + +const CASES: &[&str] = &[ + "/OCGs[5 0 R 6 3 R]", + "/OCGs[5 0 R 6 3 R]/D null", + "/OCGs[5 0 R 6 3 R]/D 42", + "/OCGs[5 0 R 6 3 R]/D 99 0 R", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", +]; + +fn incomplete_inventory(standard: &str, off: bool) { + let cases: &[&str] = if off { + &["/OCGs[5 0 R]/D<>", "/D<>"] + } else { + &["/OCGs[6 3 R]/D<>"] + }; + for properties in cases { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "incomplete.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), true), ("Shown".into(), false)]); + painted(&bytes, &[true, false]); + super::assert_vector(&bytes); + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1); + let warning = warnings[0].as_str().unwrap(); + assert!(warning.contains("incomplete.pdf") && warning.contains("layer list incomplete"), "{warning}"); + assert!(!warning.contains("layer state not readable"), "{warning}"); + if standard == "x4" { + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + } + } +} + +#[test] +fn plain_incomplete_inventory_preserves_off() { + incomplete_inventory("none", true); +} + +#[test] +fn pdfx_incomplete_inventory_preserves_off() { + incomplete_inventory("x4", true); +} + +#[test] +fn plain_incomplete_inventory_preserves_on() { + incomplete_inventory("none", false); +} + +#[test] +fn pdfx_incomplete_inventory_preserves_on() { + incomplete_inventory("x4", false); +} + +#[test] +fn incomplete_inventory_reports_both_warnings_during_fallback() { + for standard in [Standard::None, Standard::PdfX4] { + let source = properties_fixture("/OCProperties<>>>", "Hidden"); + let doc = document(source, &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert_eq!(report.warnings.len(), 1); + let warning = &report.warnings[0]; + assert!( + warning.contains("layers.pdf") && warning.contains("layer list incomplete") && warning.contains("layer state not readable"), + "{warning}" + ); + painted(&report.bytes, &[true, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } +} + +fn visible_recovery(source: Vec, standard: &str) { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session.execute("file.place", &json!({"base64": base64_encode(&source), "name": "visible-recovery.pdf", "x": 0, "y": 0, "width": 100})).unwrap(); + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].as_str().unwrap().contains("visible-recovery.pdf")); + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + let groups = catalog(&bytes); + assert_eq!(groups.len(), 2); + assert!(groups.iter().all(|(name, hidden)| !name.is_empty() && !hidden)); + super::assert_vector(&bytes); + painted(&bytes, &[true, true]); + if standard == "x4" { + assert!(designcraft_pdf::check_pdfx4(&bytes).is_empty()); + } +} + +#[test] +fn plain_export_recovers_malformed_on_when_defaults_are_visible() { + visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "none"); +} + +#[test] +fn pdfx_export_recovers_malformed_on_when_defaults_are_visible() { + visible_recovery(super::support::fixture("/BaseState/ON/ON 42", "Hidden"), "x4"); +} + +#[test] +fn plain_export_names_visible_group_when_display_name_is_missing() { + visible_recovery(nameless_fixture("/BaseState/ON"), "none"); +} + +#[test] +fn pdfx_export_names_visible_group_when_display_name_is_missing() { + visible_recovery(nameless_fixture("/BaseState/ON"), "x4"); +} + +#[test] +fn missing_display_name_fails_when_an_override_could_match() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(nameless_fixture("/BaseState/ON"), &[&["Shown"]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "a nameless group could match the explicit hidden name"); + } +} + +#[test] +fn malformed_on_fails_when_base_state_can_hide_groups() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(super::support::fixture("/BaseState/OFF/ON 42", "Hidden"), &[&[]]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "all-ON recovery would expose groups hidden by BaseState OFF"); + } +} + +#[test] +fn name_repair_preserves_readable_default_off_state() { + for standard in [Standard::None, Standard::PdfX4] { + let doc = document(nameless_fixture("/BaseState/ON/OFF[6 3 R]"), &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("layers.pdf")); + assert_eq!(catalog(&report.bytes).iter().filter(|(_, hidden)| *hidden).count(), 1); + painted(&report.bytes, &[true, false]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } +} + +fn fallback_exports(standard: Standard) { + let mut failures = Vec::new(); + for properties in CASES { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let doc = document(source, &[&[], &["Shown"]]); + + let report = match export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, created: Some(0), ..Default::default() }) { + Ok(report) => report, + Err(error) => { + failures.push(format!("{properties}: {error}")); + continue; + } + }; + + if report.warnings.len() != 1 { + failures.push(format!("{properties}: expected one fallback warning, got {:?}", report.warnings)); + continue; + } + assert!(report.warnings[0].contains("layers.pdf")); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Hidden".into(), false), ("Shown".into(), false), ("Shown".into(), true)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true, false, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } + } + assert!(failures.is_empty(), "fallback export failures: {failures:#?}"); +} + +#[test] +fn plain_fallback_shows_all_layers_and_preserves_frame_overrides() { + fallback_exports(Standard::None); +} + +#[test] +fn pdfx_fallback_shows_all_layers_and_preserves_frame_overrides() { + fallback_exports(Standard::PdfX4); +} + +#[test] +fn file_export_reports_fallback_with_filename_and_preserves_overrides() { + for properties in CASES { + for standard in ["none", "x4"] { + for hidden in [false, true] { + let source = properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"); + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1, "facingPages": false, "margins": 0})).unwrap(); + session + .execute("file.place", &json!({"base64": base64_encode(&source), "name": "fallback.pdf", "x": 0, "y": 0, "width": 100})) + .unwrap(); + if hidden { + session.execute("object.layerOptions", &json!({"hidden": ["Shown"]})).unwrap(); + } + + let result = session.execute("file.exportPdf", &json!({"standard": standard})).unwrap(); + + let warnings = result["warnings"].as_array().unwrap(); + assert_eq!(warnings.len(), 1, "{properties}: {result}"); + assert!(warnings[0].as_str().unwrap().contains("fallback.pdf")); + let bytes = designcraft_engine::cmd::base64_decode(result["base64"].as_str().unwrap()); + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), hidden)]); + painted(&bytes, &[!hidden, true]); + } + } + } +} + +#[test] +fn contradictory_and_unresolved_states_fail_even_with_fallback_or_overrides() { + for properties in [ + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R 99 0 R]", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + "/OCGs[5 0 R 6 3 R]/D<>", + ] { + for standard in [Standard::None, Standard::PdfX4] { + for hidden in [&[][..], &["Shown"][..]] { + let doc = document(properties_fixture(&format!("/OCProperties<<{properties}>>"), "Hidden"), &[hidden]); + + let result = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }); + + assert!(result.is_err(), "unsafe state accepted: {properties}"); + } + } + } +} diff --git a/crates/engine/tests/support/optional_content_xref.rs b/crates/engine/tests/support/optional_content_xref.rs new file mode 100644 index 000000000..9730969e2 --- /dev/null +++ b/crates/engine/tests/support/optional_content_xref.rs @@ -0,0 +1,143 @@ +use super::support::{catalog, document, fixture, nameless_fixture, painted}; +use designcraft_compose::Cache; +use designcraft_doc::{AssetId, Content}; +use designcraft_pdf::{BookletKind, BookletOptions, PdfOptions, Standard, export_booklet, export_pdf, export_pdf_with_report, merge_pdfs}; +use std::sync::Arc; + +fn broken_xref(bytes: Vec) -> Vec { + let text = String::from_utf8(bytes).unwrap(); + let (body, _) = text.rsplit_once("startxref").unwrap(); + let bytes = format!("{body}startxref\n1\n%%EOF\n").into_bytes(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("hayro must repair the synthetic broken xref"); + assert_eq!(pdf.pages().len(), 1); + let root = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + assert!(root.get::>(b"OCProperties").is_some()); + bytes +} + +fn all_visible(standard: Standard) { + let source = broken_xref(fixture("/BaseState/ON", "Hidden")); + painted(&source, &[true, true]); + let doc = document(source, &[&[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap(); + + assert!(report.warnings.is_empty(), "{:?}", report.warnings); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true]); + if standard == Standard::PdfX4 { + assert!(designcraft_pdf::check_pdfx4(&report.bytes).is_empty()); + } +} + +#[test] +fn plain_export_accepts_repaired_xref_when_all_layers_are_visible() { + all_visible(Standard::None); +} + +#[test] +fn pdfx_export_accepts_repaired_xref_when_all_layers_are_visible() { + all_visible(Standard::PdfX4); +} + +#[test] +fn booklet_accepts_repaired_xref_when_all_layers_are_visible() { + let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&[]]); + let options = BookletOptions { kind: BookletKind::TwoUpConsecutive, ..Default::default() }; + + let report = export_booklet(&doc, &Cache::new(), &options).unwrap(); + + assert!(report.warnings.is_empty()); + assert_eq!(catalog(&report.bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&report.bytes); + painted(&report.bytes, &[true, true, false, false]); +} + +#[test] +fn book_merge_accepts_repaired_xref_when_all_layers_are_visible() { + let source = broken_xref(fixture("/BaseState/ON", "Hidden")); + + let bytes = merge_pdfs(&[(source, vec![(100.0, 100.0)])], None).unwrap(); + + assert_eq!(catalog(&bytes), [("Hidden".into(), false), ("Shown".into(), false)]); + super::assert_vector(&bytes); + painted(&bytes, &[true, true]); +} + +#[test] +fn repaired_xref_with_default_off_fails_with_named_damage_error() { + let doc = document(broken_xref(fixture("/BaseState/ON/OFF[6 3 R]", "Hidden")), &[&[]]); + + for standard in [Standard::None, Standard::PdfX4] { + let error = export_pdf(&doc, &Cache::new(), &PdfOptions { standard, ..Default::default() }).unwrap_err().to_string(); + assert!(error.contains("layers.pdf"), "{error}"); + assert!(error.contains("cross-reference table is damaged"), "{error}"); + assert!(!error.contains("optional-content state"), "{error}"); + } +} + +#[test] +fn repaired_xref_with_explicit_hidden_layer_fails_with_named_damage_error() { + let doc = document(broken_xref(fixture("/BaseState/ON", "Hidden")), &[&["Hidden"]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}"); +} + +#[test] +fn repaired_xref_needing_generated_name_fails_with_named_damage_error() { + let doc = document(broken_xref(nameless_fixture("/BaseState/ON")), &[&[]]); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("layers.pdf") && error.contains("cross-reference table is damaged"), "{error}"); +} + +fn two_assets(first: Vec, second: Vec) -> designcraft_doc::Document { + let mut doc = document(first, &[&[], &[]]); + let original = doc.assets.values().next().unwrap().as_ref().clone(); + Arc::make_mut(doc.assets.values_mut().next().unwrap()).name = "first.pdf".into(); + let id = AssetId(doc.alloc()); + let mut other = original; + other.id = id; + other.name = "second.pdf".into(); + other.data = Arc::new(second); + doc.add_asset(other); + let spread = Arc::make_mut(doc.spreads.first_mut().unwrap()); + let item = Arc::make_mut(spread.items.last_mut().unwrap()); + let Content::Graphic(graphic) = &mut item.content else { panic!("fixture must have a graphic") }; + graphic.asset = id; + doc +} + +#[test] +fn error_identifies_unresolvable_pdf_among_two_placements() { + let doc = two_assets(fixture("/BaseState/ON", "Hidden"), fixture("/OFF[99 0 R]", "Hidden")); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert_eq!(error, "PDF writer error: second.pdf: the placed PDF's optional-content state could not be preserved safely"); +} + +#[test] +fn first_placement_error_is_not_overwritten() { + let doc = two_assets(fixture("/OFF[99 0 R]", "Hidden"), fixture("/OFF[98 0 R]", "Hidden")); + + let error = export_pdf(&doc, &Cache::new(), &PdfOptions::default()).unwrap_err().to_string(); + + assert!(error.contains("first.pdf"), "{error}"); + assert!(!error.contains("second.pdf"), "{error}"); +} + +#[test] +fn repeated_recoverable_variant_reports_one_warning_and_paints_each_placement() { + let doc = document(fixture("/BaseState/Unchanged", "Hidden"), &[&[], &[]]); + + let report = export_pdf_with_report(&doc, &Cache::new(), &PdfOptions::default()).unwrap(); + + assert_eq!(report.warnings.len(), 1); + assert!(report.warnings[0].contains("layers.pdf")); + painted(&report.bytes, &[true, true, true, true]); +} diff --git a/crates/epub/src/chapters.rs b/crates/epub/src/chapters.rs new file mode 100644 index 000000000..ede96f390 --- /dev/null +++ b/crates/epub/src/chapters.rs @@ -0,0 +1,154 @@ +use super::css::{export_mapping, para_classes}; +use super::html::Block; +use super::markup::xml_text; +use super::*; + +/// Whether an EPUB chapter file starts before a paragraph of `style` (Split Document): never +/// when it's off, before each paragraph of the one style it names, or before each paragraph of +/// a style whose Export Tagging has Split Document on. +pub(crate) fn starts_chapter(split: &EpubSplit, st: &Styles, style: &str) -> bool { + match split { + EpubSplit::Off => false, + EpubSplit::Style(name) => name == style, + EpubSplit::ExportTags => st.export_tag(style, false).is_some_and(|e| e.split), + } +} + +/// Where the EPUB body splits into chapter files (see [`starts_chapter`]). +pub(crate) const CHAPTER_MARK: &str = ""; + +/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed +/// and the one a split goes through reopened in the next; a part with nothing but the start of a +/// section (a story that opens with its `h1`) moves that start into the next part. +pub(crate) fn split_chapters(body: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut carry: Option = None; + for piece in body.split(CHAPTER_MARK) { + let mut part = carry.take().unwrap_or_default(); + part.push_str(piece); + if let Some(open) = part.rfind("")) { + let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); + if part[tag_end..].trim().is_empty() { + carry = Some(part[open..].to_string()); + part.truncate(open); + } else { + part.push_str("\n"); + carry = Some("
\n".to_string()); + } + } + if !part.trim().is_empty() { + out.push(part); + } + } + if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) + && let Some(last) = out.last_mut() + { + // Only an opening tag can be left over; close it where it stands. + last.push_str(&rest); + last.push_str("
\n"); + } + if out.is_empty() { + out.push(String::new()); + } + out +} + +/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another +/// chapter file pointed at that file. +pub(crate) fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { + const HREF: &str = "href=\"#"; + let mut out = String::with_capacity(chapter.len()); + let mut rest = chapter; + while let Some(i) = rest.find(HREF) { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i + HREF.len()..).unwrap_or_default(); + let id = tail.split('"').next().unwrap_or_default(); + match files.get(id) { + Some(file) if file != own => { + let _ = write!(out, "href=\"{file}#"); + } + _ => out.push_str(HREF), + } + rest = tail; + } + out.push_str(rest); + out +} + +/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. +pub(crate) fn chapter_file(k: usize) -> String { + if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } +} + +/// The level of a heading element (`h1` → 1 … `h6` → 6). +pub(crate) fn heading_level(tag: &str) -> Option { + tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) +} + +/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in +/// reading order, or, in a document without headings, each story's first paragraph (level 1). +pub(crate) fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { + let pc = para_classes(&doc.styles); + let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; + let mut headings = Vec::new(); + let mut firsts = Vec::new(); + for (k, b) in order.iter().enumerate() { + let Block::Story(sid) = b else { continue }; + // The endnote story isn't written (the endnotes section stands in for it). + if doc.endnote_story == Some(*sid) { + continue; + } + let Some(st) = doc.story(*sid) else { continue }; + let first = label(st.text.split('\n').next().unwrap_or("")); + if !first.is_empty() { + firsts.push((format!("s{k}"), first, 1)); + } + for (pi, r) in st.para_ranges().iter().enumerate() { + let Some(pf) = st.paras.get(pi) else { continue }; + let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); + let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; + let text = label(st.text.get(r.clone()).unwrap_or("")); + if !text.is_empty() { + headings.push((format!("s{k}-p{pi}"), text, level)); + } + } + } + if headings.is_empty() { firsts } else { headings } +} + +/// The table of contents as nested `li`/`ol` items (the content of the navigation's outer +/// `ol`): a deeper level goes into the previous item, skipped levels count as one. +pub(crate) fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { + let mut out = String::new(); + let mut open: Vec = Vec::new(); + for (target, label, level) in entries { + let file = files.get(target).map_or("content.xhtml", String::as_str); + let item = format!("
  • {}", xml_text(label)); + match open.last().copied() { + None => open.push(*level), + Some(top) if *level > top => { + out.push_str("
      "); + open.push(*level); + } + Some(_) => { + // Close the levels whose parent is as deep as this entry or deeper. + while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { + open.pop(); + out.push_str("
    "); + } + out.push_str("
  • "); + if let Some(t) = open.last_mut() { + *t = (*t).min(*level); + } + } + } + out.push_str(&item); + } + for i in (0..open.len()).rev() { + out.push_str(""); + if i > 0 { + out.push_str(""); + } + } + out +} diff --git a/crates/epub/src/css.rs b/crates/epub/src/css.rs new file mode 100644 index 000000000..526b10a40 --- /dev/null +++ b/crates/epub/src/css.rs @@ -0,0 +1,226 @@ +use super::fonts::{EmbeddedFont, css_weight_style, font_file, generic_family}; +use super::markup::{css_string, slug, valid_xml_char}; +use super::*; + +/// Class names for `names`, in order: [`slug`]s made unique with a `-2`, `-3`… suffix, so styles +/// whose names slug alike ("Body Text" / "Body-Text", or non-ASCII names) keep their own rules. +fn class_names<'a>(names: impl Iterator) -> BTreeMap<&'a str, String> { + let mut out = BTreeMap::new(); + let mut used = std::collections::BTreeSet::new(); + for name in names { + let base = slug(name); + let mut class = base.clone(); + let mut n = 1; + while !used.insert(class.clone()) { + n += 1; + class = format!("{base}-{n}"); + } + out.insert(name, class); + } + out +} + +pub(crate) fn para_classes(st: &Styles) -> BTreeMap<&str, String> { + class_names(st.paragraph.iter().map(|s| s.name.as_str())) +} + +pub(crate) fn char_classes(st: &Styles) -> BTreeMap<&str, String> { + class_names(st.character.iter().map(|s| s.name.as_str())) +} + +fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { + let c = doc.resolve_color(swatch, tint)?; + let [r, g, b, _] = c.to_rgba8(1.0); + Some(format!("#{r:02x}{g:02x}{b:02x}")) +} + +pub(crate) fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap, out: &mut String) { + let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); + // The face's own weight, so an embedded Semibold or Light face is the one chosen. + let (weight, style) = css_weight_style(&c.font_style); + let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); + let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); + if c.tracking != 0.0 { + let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); + } + match c.capitalization { + designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), + designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { + out.push_str("font-variant: small-caps; ") + } + _ => {} + } + // One declaration: a later `text-decoration` would replace the earlier one. + match (c.underline, c.strikethrough) { + (true, true) => out.push_str("text-decoration: underline line-through; "), + (true, false) => out.push_str("text-decoration: underline; "), + (false, true) => out.push_str("text-decoration: line-through; "), + (false, false) => {} + } + if let Some(h) = hex(doc, &c.fill, c.fill_tint) { + let _ = write!(out, "color: {h}; "); + } +} + +fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { + let align = match p.align { + Align::Left | Align::TowardsSpine => "left", + Align::Center => "center", + Align::Right | Align::AwayFromSpine => "right", + _ => "justify", + }; + let _ = write!(out, "text-align: {align}; "); + let em = |v: f64| v / base_size; + let _ = write!( + out, + "margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ", + em(p.space_before), + em(p.right_indent), + em(p.space_after), + em(p.left_indent), + em(p.first_line_indent), + line_px + ); + out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " }); +} + +/// The stylesheet for all paragraph and character styles. +pub fn stylesheet(doc: &Document) -> String { + stylesheet_with(doc, &BTreeMap::new(), &[]) +} + +/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the +/// embedded fonts (packaged as [`font_file`] names them). +pub(crate) fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { + let st: &Styles = &doc.styles; + let mut faces = String::new(); + for (k, f) in fonts.iter().enumerate() { + let (path, _) = font_file(k, f); + let (weight, style) = css_weight_style(&f.style); + let _ = writeln!( + faces, + "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", + css_string(&f.family) + ); + } + let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); + let base = basic.size.max(1.0); + let mut css = faces; + css.push_str( + "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", + ); + let (pc, cc) = (para_classes(st), char_classes(st)); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (pp, cp) = st.resolve_para_style(&ps.name); + let lead = match cp.leading { + designcraft_doc::Leading::Auto => pp.auto_leading, + designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0), + }; + let mut rule = String::new(); + para_css(&pp, base, lead, &mut rule); + char_css(doc, &cp, base, generics, &mut rule); + let (tag, class) = export_mapping(st, &ps.name, false, &pc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } + } + for cs in &st.character { + if cs.name == story::NO_CHAR_STYLE { + continue; + } + let mut rule = String::new(); + let c = &cs.chars; + if let Some(f) = &c.font_style { + let f = f.to_lowercase(); + if f.contains("bold") { + rule.push_str("font-weight: bold; "); + } + if f.contains("italic") { + rule.push_str("font-style: italic; "); + } + } + if let Some(fill) = &c.fill + && let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0)) + { + let _ = write!(rule, "color: {h}; "); + } + if let Some(sz) = c.size { + let _ = write!(rule, "font-size: {:.3}em; ", sz / base); + } + let (tag, class) = export_mapping(st, &cs.name, true, &cc); + if let Some(selector) = class_selector(tag, &class) { + let _ = writeln!(css, "{selector} {{ {rule}}}"); + } + } + css +} + +pub(crate) fn override_css(doc: &Document, o: &CharAttrs) -> String { + let mut s = String::new(); + if let Some(f) = &o.font_style { + let f = f.to_lowercase(); + if f.contains("bold") { + s.push_str("font-weight: bold; "); + } + if f.contains("italic") { + s.push_str("font-style: italic; "); + } + } + if let Some(fill) = &o.fill + && let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0)) + { + let _ = write!(s, "color: {h}; "); + } + if o.underline == Some(true) { + s.push_str("text-decoration: underline; "); + } + if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { + s.push_str("text-transform: uppercase; "); + } + s.trim_end().to_string() +} + +/// Elements Export Tagging may choose (paragraph or character level). +fn is_tag(t: &str, character: bool) -> bool { + if character { + matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q") + } else { + matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div") + } +} + +/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, +/// collision-free class when no custom class was supplied, and the existing tag fallback. +pub(crate) fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { + let et = st.export_tag(name, character); + let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); + let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); + // Match the characters xml_attr actually emits, before escaping either output context. + let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); + (tag, class) +} + +/// Each class token must match on the same element. Escape CSS identifier punctuation and +/// leading digits without changing the class written into XHTML. +fn class_selector(tag: &str, class: &str) -> Option { + let mut tokens = class.split_ascii_whitespace().peekable(); + // A nonempty but whitespace-only custom class has no token to select. Never emit a bare + // element selector, which would apply this style to unrelated paragraphs or character runs. + tokens.peek()?; + let mut selector = tag.to_string(); + for token in tokens { + selector.push('.'); + for (i, c) in token.chars().enumerate() { + let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); + if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { + selector.push(c); + } else { + let _ = write!(selector, "\\{:x} ", c as u32); + } + } + } + Some(selector) +} diff --git a/crates/epub/src/fonts.rs b/crates/epub/src/fonts.rs new file mode 100644 index 000000000..746781971 --- /dev/null +++ b/crates/epub/src/fonts.rs @@ -0,0 +1,83 @@ +use super::{BTreeMap, Document, Styles}; + +/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). +#[derive(Clone, Debug)] +pub struct EmbeddedFont { + pub family: String, + pub style: String, + pub data: Vec, +} + +/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an +/// export can embed. +pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { + let st: &Styles = &doc.styles; + let mut out: Vec<(String, String)> = Vec::new(); + for ps in &st.paragraph { + if ps.name == designcraft_doc::NO_PARA_STYLE { + continue; + } + let (_, cp) = st.resolve_para_style(&ps.name); + let f = (cp.font_family.clone(), cp.font_style.clone()); + if !f.0.is_empty() && !out.contains(&f) { + out.push(f); + } + } + out +} + +/// The CSS generic family for `family`: the known one, else a guess from the name +/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ +/// `Verdana` → sans-serif), else serif. +pub(crate) fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { + const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; + if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { + return g; + } + let f = family.to_lowercase(); + let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); + if has(&["mono", "courier", " code", "consol"]) { + "monospace" + } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { + "sans-serif" + } else { + "serif" + } +} + +/// CSS weight and style of a style name (`Semibold Italic` → 600, italic). +pub(crate) fn css_weight_style(style: &str) -> (u16, &'static str) { + let s = style.to_lowercase().replace([' ', '-'], ""); + let weight = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("bold", 700), + ("black", 900), + ("heavy", 900), + ] + .iter() + .find(|(n, _)| s.contains(n)) + .map_or(400, |(_, w)| *w); + (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) +} + +/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). +pub(crate) fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { + let (ext, mime) = match f.data.get(..4) { + Some(b"OTTO") => ("otf", "font/otf"), + Some(b"wOFF") => ("woff", "font/woff"), + Some(b"wOF2") => ("woff2", "font/woff2"), + _ => ("ttf", "font/ttf"), + }; + let name: String = + format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); + (format!("fonts/{k}-{name}.{ext}"), mime) +} diff --git a/crates/epub/src/html.rs b/crates/epub/src/html.rs new file mode 100644 index 000000000..ce9d7176c --- /dev/null +++ b/crates/epub/src/html.rs @@ -0,0 +1,212 @@ +use super::chapters::toc_entries; +use super::css::stylesheet_with; +use super::markup::{language_tag, xml_attr, xml_text}; +use super::story_writer::StoryWriter; +use super::*; + +/// Reading order of document content: (page, y, x) of each story's first frame and of graphics. +#[derive(Debug, Clone, PartialEq)] +pub enum Block { + Story(StoryId), + Image(ItemId), +} + +pub fn reading_order(doc: &Document) -> Vec { + // Articles, when there are any to export, decide the order (and what's included). + if doc.articles.iter().any(|a| a.export) { + let mut out = Vec::new(); + let mut seen = Vec::new(); + for a in doc.articles.iter().filter(|a| a.export) { + for id in &a.items { + let Some(top) = doc.item(*id) else { continue }; + let mut push = |it: &Item| match &it.content { + Content::Text(tf) if !seen.contains(&tf.story) => { + seen.push(tf.story); + out.push(Block::Story(tf.story)); + } + Content::Graphic(_) => out.push(Block::Image(it.id)), + _ => {} + }; + top.walk(&mut |i| push(i)); + } + } + return out; + } + let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); + let mut seen_stories = Vec::new(); + for (si, sp) in doc.spreads.iter().enumerate() { + let first = doc.first_page_of_spread(si); + // Preserve traversal order while carrying each group's transform to spread space. + // The iterator stack grows with nesting depth, without repeated document lookups. + let mut stack = vec![(sp.items.iter(), Affine::IDENTITY)]; + while let Some((items, parent)) = stack.last_mut() { + let Some(it) = items.next() else { + stack.pop(); + continue; + }; + let xf = *parent * it.xf; + let children = it.children(); + if !children.is_empty() { + stack.push((children.iter(), xf)); + } + if it.hidden { + continue; + } + let block = match &it.content { + Content::Text(tf) => { + let Some(story) = doc.story(tf.story) else { continue }; + // A story is placed where its first frame is. + if story.frames.first() != Some(&it.id) || seen_stories.contains(&tf.story) || story.text.trim().is_empty() { + continue; + } + seen_stories.push(tf.story); + Block::Story(tf.story) + } + Content::Graphic(_) => Block::Image(it.id), + _ => continue, + }; + // Transform the path once: rotating a local axis-aligned box again would + // enlarge it and give a different top-left from the actual spread geometry. + let b = it.path.transformed(xf).bounds().unwrap_or(Rect::ZERO); + let page = first + sp.page_at_x(b.center().x).unwrap_or(0); + let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64); + keyed.push((key, block)); + } + } + let _ = SpreadRef::Doc(0); + keyed.sort_by_key(|(k, _)| *k); + keyed.into_iter().map(|(_, b)| b).collect() +} + +/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name +/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same +/// name apart. +pub(crate) fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { + let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); + let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); + let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); + let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); + if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } +} + +fn ext(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + "video/mp4" => "mp4", + "video/quicktime" => "mov", + "video/webm" => "webm", + "video/ogg" => "ogv", + "audio/mpeg" => "mp3", + "audio/mp4" => "m4a", + "audio/wav" => "wav", + "audio/ogg" => "ogg", + "audio/aac" => "aac", + _ => "png", + } +} + +/// The document content as HTML, with the files it names. +pub(crate) struct Body<'a> { + pub(crate) html: String, + /// Images, video and sound by package name: (media type, bytes). + pub(crate) images: BTreeMap, + /// Table of contents: (element id, label, level). + pub(crate) toc: Vec<(String, String, u8)>, + /// Asset names of the images written without alt text. + no_alt: Vec, +} + +/// The content in reading order as HTML sections and figures; `img_src` names each image +/// (a package path, or a data URI for single-file HTML). +pub(crate) fn body_html<'a>(doc: &'a Document, epub: bool, split: Option<&EpubSplit>, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { + let order = reading_order(doc); + let mut writer = StoryWriter::new(doc, epub, Some(img_src)); + if let Some(split) = split { + writer.split = split.clone(); + } + let mut body = String::new(); + let mut toc = toc_entries(doc, &order); + for (k, b) in order.iter().enumerate() { + match b { + // The endnote frame's text is generated from the endnotes, which end the body as a + // linked section instead. + Block::Story(sid) if doc.endnote_story == Some(*sid) => {} + Block::Story(sid) => { + let anchor = format!("s{k}"); + let _ = write!(body, "
    \n{}
    \n", writer.story(*sid, epub.then_some(anchor.as_str()))); + } + Block::Image(iid) => { + let Some(it) = doc.item(*iid) else { continue }; + let Some((markup, media)) = writer.graphic(it) else { continue }; + if media { + let _ = writeln!(body, "
    {markup}
    "); + continue; + } + // Object Export Options: alignment and a page break before. + let eo = &it.export_options; + let mut css = String::new(); + if matches!(eo.align.as_str(), "left" | "center" | "right") { + css.push_str(&format!("text-align:{};", eo.align)); + } + if eo.page_break_before { + css.push_str("page-break-before:always;break-before:page;"); + } + let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; + let _ = writeln!(body, "{markup}
    "); + } + } + } + let endnotes = writer.endnotes_section(); + if !endnotes.is_empty() { + let heading = &doc.endnote_options.heading; + if !heading.trim().is_empty() { + toc.push(("endnotes".into(), heading.clone(), 1)); + } + body.push_str(&endnotes); + } + Body { html: body, images: writer.images, toc, no_alt: writer.no_alt } +} + +/// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as +/// decorative (`alt=""`), which an export can report. +pub fn images_without_alt(doc: &Document) -> Vec { + body_html(doc, true, None, &|name, _, _| name.to_string()).no_alt +} + +/// Options for [`export_html`]. +#[derive(Clone, Debug, Default)] +pub struct HtmlOptions { + pub title: Option, + pub language: Option, + /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). + pub generic_families: BTreeMap, +} + +/// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the +/// document's stories and graphics in reading order. +pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { + let title = opts.title.clone().unwrap_or_else(|| doc.metadata_title().to_string()); + let language = opts.language.as_deref().unwrap_or("en"); + let lang = language_tag(language); + let body = body_html(doc, false, None, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; + format!( + "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", + xml_text(&title), + stylesheet_with(doc, &opts.generic_families, &[]) + ) +} + +pub(crate) fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() { + out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' }); + } + } + out +} diff --git a/crates/epub/src/lib.rs b/crates/epub/src/lib.rs index 348b9c80e..c102a54fe 100644 --- a/crates/epub/src/lib.rs +++ b/crates/epub/src/lib.rs @@ -15,10 +15,35 @@ use std::io::Write; use designcraft_doc::geom::{Affine, Rect}; use designcraft_doc::{ - Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, ParaProps, - SpreadRef, Story, StoryId, Styles, Table, story, + Align, AnchorPosition, AnchoredObject, CharAttrs, CharProps, Content, Document, ENDNOTE_REF, EpubSplit, FOOTNOTE_REF, Item, ItemId, OBJECT_MARK, + ParaProps, SpreadRef, Story, StoryId, Styles, Table, story, }; +mod chapters; +mod css; +mod fonts; +mod html; +mod markup; +mod package; +mod story_writer; + +#[cfg(test)] +use chapters::{CHAPTER_MARK, link_across_chapters, nav_list, split_chapters}; +#[cfg(test)] +use css::char_css; +pub use css::stylesheet; +pub use fonts::{EmbeddedFont, stylesheet_fonts}; +#[cfg(test)] +use fonts::{css_weight_style, generic_family}; +#[cfg(test)] +use html::base64; +pub use html::{Block, HtmlOptions, export_html, images_without_alt, reading_order}; +pub use markup::{EpubError, slug}; +#[cfg(test)] +use package::iso8601; +pub use package::{FixedPage, contents_title, export_epub, export_fixed_epub}; +pub use story_writer::story_html; + #[derive(Clone, Debug)] pub struct EpubOptions { pub title: Option, @@ -38,242 +63,8 @@ pub struct EpubOptions { pub modified: Option, /// Title of the navigation (table of contents); from `language` when absent. pub toc_title: Option, -} - -/// A font file packaged with an EPUB: the face of `family` in `style` (`Bold Italic`…). -#[derive(Clone, Debug)] -pub struct EmbeddedFont { - pub family: String, - pub style: String, - pub data: Vec, -} - -/// The `(family, style)` pairs the stylesheet names, one each, in style order: the fonts an -/// export can embed. -pub fn stylesheet_fonts(doc: &Document) -> Vec<(String, String)> { - let st: &Styles = &doc.styles; - let mut out: Vec<(String, String)> = Vec::new(); - for ps in &st.paragraph { - if ps.name == designcraft_doc::NO_PARA_STYLE { - continue; - } - let (_, cp) = st.resolve_para_style(&ps.name); - let f = (cp.font_family.clone(), cp.font_style.clone()); - if !f.0.is_empty() && !out.contains(&f) { - out.push(f); - } - } - out -} - -/// The CSS generic family for `family`: the known one, else a guess from the name -/// (`… Mono`/`Courier`/`… Code` → monospace, `… Sans`/`Gothic`/`Grotesk`/`Helvetica`/`Arial`/ -/// `Verdana` → sans-serif), else serif. -fn generic_family<'a>(family: &str, known: &'a BTreeMap) -> &'a str { - const GENERIC: [&str; 6] = ["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"]; - if let Some(g) = known.get(family).and_then(|g| GENERIC.iter().find(|x| **x == g.as_str())) { - return g; - } - let f = family.to_lowercase(); - let has = |words: &[&str]| words.iter().any(|w| f.contains(w)); - if has(&["mono", "courier", " code", "consol"]) { - "monospace" - } else if has(&["sans", "gothic", "grotesk", "grotesque", "helvetica", "arial", "verdana"]) { - "sans-serif" - } else { - "serif" - } -} - -/// CSS weight and style of a style name (`Semibold Italic` → 600, italic). -fn css_weight_style(style: &str) -> (u16, &'static str) { - let s = style.to_lowercase().replace([' ', '-'], ""); - let weight = [ - ("thin", 100), - ("extralight", 200), - ("ultralight", 200), - ("light", 300), - ("medium", 500), - ("semibold", 600), - ("demibold", 600), - ("extrabold", 800), - ("ultrabold", 800), - ("bold", 700), - ("black", 900), - ("heavy", 900), - ] - .iter() - .find(|(n, _)| s.contains(n)) - .map_or(400, |(_, w)| *w); - (weight, if s.contains("italic") || s.contains("oblique") { "italic" } else { "normal" }) -} - -/// Package path and media type of an embedded font (`fonts/{k}-{family}-{style}.{ext}`). -fn font_file(k: usize, f: &EmbeddedFont) -> (String, &'static str) { - let (ext, mime) = match f.data.get(..4) { - Some(b"OTTO") => ("otf", "font/otf"), - Some(b"wOFF") => ("woff", "font/woff"), - Some(b"wOF2") => ("woff2", "font/woff2"), - _ => ("ttf", "font/ttf"), - }; - let name: String = - format!("{}-{}", f.family, f.style).chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let name = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-"); - (format!("fonts/{k}-{name}.{ext}"), mime) -} - -/// An ISO 8601 UTC timestamp (`2026-10-13T09:30:00Z`) for Unix seconds `t`. -/// A time outside chrono's year range still converts: the calendar repeats every 400 years, so the -/// instant is moved into range by whole cycles and only the year is carried (matches the old -/// `civil_from_days`), rather than resetting to the epoch. -fn iso8601(t: i64) -> String { - let (y, dt) = designcraft_doc::time::utc_parts(t, 0); - // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. - format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%SZ")) -} - -/// The package's `dcterms:modified`: the given time, else the document's last save or creation. -fn modified(doc: &Document, opts: &EpubOptions) -> String { - let t = opts.modified.or((doc.modified > 0).then_some(doc.modified)).or((doc.created > 0).then_some(doc.created)); - t.map_or_else(|| "2026-01-01T00:00:00Z".to_string(), iso8601) -} - -/// The book's identifier: the given one, else a `urn:uuid` derived from the title and the -/// document's creation time, so a book keeps its identifier from export to export and two books -/// with the same title don't share one. -fn identifier(doc: &Document, opts: &EpubOptions, title: &str) -> String { - if let Some(id) = opts.identifier.clone().filter(|i| !i.trim().is_empty()) { - return id; - } - // Two FNV-1a 64-bit hashes (different offsets) as 128 bits, shaped as a version-5-style UUID. - let seed = format!("{title}\n{}", doc.created); - let fnv = |offset: u64| seed.bytes().fold(offset, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)); - let (a, b) = (fnv(0xcbf2_9ce4_8422_2325), fnv(0x84222325_cbf29ce4)); - let hi = (a & 0xffff_ffff_ffff_0fff) | 0x5000; - let lo = (b & 0x3fff_ffff_ffff_ffff) | 0x8000_0000_0000_0000; - format!("urn:uuid:{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", hi >> 32, (hi >> 16) & 0xffff, hi & 0xffff, lo >> 48, lo & 0xffff_ffff_ffff) -} - -/// Package name and media type of a cover image: JPEG by its signature, else PNG. -fn cover_file(data: &[u8]) -> (&'static str, &'static str) { - if data.starts_with(&[0xFF, 0xD8, 0xFF]) { ("images/cover.jpg", "image/jpeg") } else { ("images/cover.png", "image/png") } -} - -/// The table of contents' title in `language` (a BCP 47 tag or a language name's code), English -/// for languages without one here. -pub fn contents_title(language: &str) -> &'static str { - let code = language.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase(); - match code.as_str() { - "nl" => "Inhoud", - "de" => "Inhalt", - "fr" => "Table des matières", - "es" | "pt" => "Índice", - "it" => "Indice", - "ja" => "目次", - "zh" => "目录", - _ => "Contents", - } -} - -/// A page of a fixed-layout EPUB: its image (PNG), size in points and text (kept for search, -/// read-aloud and accessibility). -#[derive(Clone, Debug)] -pub struct FixedPage { - pub png: Vec, - pub width: f64, - pub height: f64, - pub text: String, -} - -/// A pre-paginated (fixed-layout) EPUB: one XHTML page per document page. -pub fn export_fixed_epub(doc: &Document, pages: &[FixedPage], opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let lang = language_tag(&opts.language); - let id = identifier(doc, opts, &title); - let modified = modified(doc, opts); - let mut manifest = String::from("\n"); - let mut spine = String::new(); - let mut files: Vec<(String, Vec)> = Vec::new(); - let mut nav_items = String::new(); - for (i, p) in pages.iter().enumerate() { - let n = i + 1; - let (w, h) = (p.width.round().max(1.0) as u32, p.height.round().max(1.0) as u32); - let xhtml = format!( - "\n\n\n{} — {n}\n\n\"\"/
    {}
    \n\n", - xml_text(&title), - story_markup(&p.text).replace('\n', "
    ") - ); - let _ = writeln!( - manifest, - "\n", - if n == 1 { " properties=\"cover-image\"" } else { "" } - ); - let _ = writeln!(spine, ""); - let _ = write!(nav_items, "
  • {n}
  • "); - files.push((format!("OEBPS/page{n}.xhtml"), xhtml.into_bytes())); - files.push((format!("OEBPS/pages/p{n}.png"), p.png.clone())); - } - let (vw, vh) = pages.first().map_or((612, 792), |p| (p.width.round() as u32, p.height.round() as u32)); - let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title) - ); - let rendition = format!( - "\npre-paginated\nauto\n" - ); - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; - let opf = package_opf(&head, " prefix=\"rendition: http://www.idpf.org/vocab/rendition/#\"", &rendition, &manifest, &spine); - let entries = files.iter().map(|(name, data)| (name.clone(), data.as_slice(), name.ends_with(".png"))); - write_package(&opf, &nav, entries) -} - -/// The package metadata both EPUB flavours share. -struct PackageHead<'a> { - lang: &'a str, - id: &'a str, - title: &'a str, - author: Option<&'a str>, - modified: &'a str, -} - -/// The OPF package document. `prefix` goes on `` (with its leading space), `meta` after -/// `dcterms:modified` (with its leading newline); `manifest` and `spine` are the item lines. -fn package_opf(h: &PackageHead, prefix: &str, meta: &str, manifest: &str, spine: &str) -> String { - let PackageHead { lang, id, title, author, modified } = h; - let author = author.map(|a| format!("{}", xml_text(a))).unwrap_or_default(); - format!( - "\n\n\n{}\n{}\n{lang}{author}\n{modified}{meta}\n\n\n\n{manifest}\n\n{spine}\n\n", - xml_text(id), - xml_text(title) - ) -} - -/// Zips an EPUB: the stored `mimetype`, `container.xml`, the OPF and nav, then `entries` in order -/// as (path, bytes, stored uncompressed). -fn write_package<'a>(opf: &str, nav: &str, entries: impl IntoIterator) -> Result, EpubError> { - let io = |e: std::io::Error| EpubError(e.to_string()); - let zerr = |e: zip::result::ZipError| EpubError(e.to_string()); - let container = "\n\n"; - let mut buf = std::io::Cursor::new(Vec::new()); - { - let mut z = zip::ZipWriter::new(&mut buf); - let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let deflate = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); - z.start_file("mimetype", stored).map_err(zerr)?; - z.write_all(b"application/epub+zip").map_err(io)?; - for (name, data) in - [("META-INF/container.xml", container.as_bytes()), ("OEBPS/content.opf", opf.as_bytes()), ("OEBPS/nav.xhtml", nav.as_bytes())] - { - z.start_file(name, deflate).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - for (name, data, store) in entries { - z.start_file(name, if store { stored } else { deflate }).map_err(zerr)?; - z.write_all(data).map_err(io)?; - } - z.finish().map_err(zerr)?; - } - Ok(buf.into_inner()) + /// Split Document: where content files start; the document's setting when absent. + pub split: Option, } impl Default for EpubOptions { @@ -288,13 +79,11 @@ impl Default for EpubOptions { fonts: Vec::new(), modified: None, toc_title: None, + split: None, } } } -#[derive(Debug)] -pub struct EpubError(pub String); - impl std::fmt::Display for EpubError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) @@ -302,1933 +91,5 @@ impl std::fmt::Display for EpubError { } impl std::error::Error for EpubError {} -fn valid_xml_char(c: char) -> bool { - matches!(c, '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') -} - -fn escape_markup(s: &str, attr: bool, story_breaks: bool) -> String { - let mut o = String::with_capacity(s.len()); - for c in s.chars() { - match c { - '&' => o.push_str("&"), - '<' => o.push_str("<"), - '>' => o.push_str(">"), - '"' => o.push_str("""), - '\'' if attr => o.push_str("'"), - '\u{E000}'..='\u{E1FF}' => {} - '\u{2028}' if story_breaks => o.push_str("
    "), - '\u{AD}' => o.push_str("­"), - c if valid_xml_char(c) => o.push(c), - _ => {} - } - } - o -} - -fn xml_text(s: &str) -> String { - escape_markup(s, false, false) -} - -fn xml_attr(s: &str) -> String { - escape_markup(s, true, false) -} - -/// Story content permits the document's discretionary line-separator marker to become markup. -/// Other contexts always serialize it as ordinary text. -fn story_markup(s: &str) -> String { - escape_markup(s, false, true) -} - -/// A conservative BCP 47 subset suitable for both HTML and EPUB language attributes. -fn language_tag(s: &str) -> &str { - if s.len() > 63 { - return "und"; - } - let mut parts = s.split('-'); - let Some(first) = parts.next() else { return "und" }; - if !(2..=8).contains(&first.len()) || !first.bytes().all(|b| b.is_ascii_alphabetic()) { - return "und"; - } - if parts.any(|p| p.is_empty() || p.len() > 8 || !p.bytes().all(|b| b.is_ascii_alphanumeric())) { - return "und"; - } - s -} - -/// Return a MIME type that is safe to place in an attribute and a data URL. Parameters are omitted -/// because exported assets are always represented as their original binary bytes. -fn safe_mime(mime: &str) -> &str { - fn token(s: &str) -> bool { - !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'+' | b'-')) - } - - let mime = mime.split_once(';').map_or(mime, |(essence, _)| essence).trim(); - if mime.len() <= 127 - && let Some((kind, subtype)) = mime.split_once('/') - && !subtype.contains('/') - && token(kind) - && token(subtype) - { - mime - } else { - "image/png" - } -} - -/// Quote arbitrary document metadata as one CSS string token. Escaping `<` is important because -/// HTML parses the contents of a `style` element before CSS parsing sees it. -fn css_string(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - for c in s.chars() { - if matches!(c, '"' | '\\' | '<' | '>' | '&') || c.is_control() { - let _ = write!(out, "\\{:x} ", c as u32); - } else { - out.push(c); - } - } - out -} - -/// CSS class name for a style name. -pub fn slug(name: &str) -> String { - let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let s = s.trim_matches('-').to_string(); - if s.is_empty() { - "basic".into() - } else if s.starts_with(|c: char| c.is_ascii_digit()) { - format!("s-{s}") - } else { - s - } -} - -/// Class names for `names`, in order: [`slug`]s made unique with a `-2`, `-3`… suffix, so styles -/// whose names slug alike ("Body Text" / "Body-Text", or non-ASCII names) keep their own rules. -fn class_names<'a>(names: impl Iterator) -> BTreeMap<&'a str, String> { - let mut out = BTreeMap::new(); - let mut used = std::collections::BTreeSet::new(); - for name in names { - let base = slug(name); - let mut class = base.clone(); - let mut n = 1; - while !used.insert(class.clone()) { - n += 1; - class = format!("{base}-{n}"); - } - out.insert(name, class); - } - out -} - -fn para_classes(st: &Styles) -> BTreeMap<&str, String> { - class_names(st.paragraph.iter().map(|s| s.name.as_str())) -} - -fn char_classes(st: &Styles) -> BTreeMap<&str, String> { - class_names(st.character.iter().map(|s| s.name.as_str())) -} - -fn hex(doc: &Document, swatch: &str, tint: f32) -> Option { - let c = doc.resolve_color(swatch, tint)?; - let [r, g, b, _] = c.to_rgba8(1.0); - Some(format!("#{r:02x}{g:02x}{b:02x}")) -} - -fn char_css(doc: &Document, c: &CharProps, base_size: f64, generics: &BTreeMap, out: &mut String) { - let _ = write!(out, "font-family: \"{}\", {}; ", css_string(&c.font_family), generic_family(&c.font_family, generics)); - // The face's own weight, so an embedded Semibold or Light face is the one chosen. - let (weight, style) = css_weight_style(&c.font_style); - let _ = write!(out, "font-weight: {weight}; font-style: {style}; "); - let _ = write!(out, "font-size: {:.3}em; ", c.size / base_size); - if c.tracking != 0.0 { - let _ = write!(out, "letter-spacing: {:.3}em; ", c.tracking / 1000.0); - } - match c.capitalization { - designcraft_doc::Capitalization::AllCaps => out.push_str("text-transform: uppercase; "), - designcraft_doc::Capitalization::SmallCaps | designcraft_doc::Capitalization::OpenTypeAllSmallCaps => { - out.push_str("font-variant: small-caps; ") - } - _ => {} - } - if c.underline { - out.push_str("text-decoration: underline; "); - } - if c.strikethrough { - out.push_str("text-decoration: line-through; "); - } - if let Some(h) = hex(doc, &c.fill, c.fill_tint) { - let _ = write!(out, "color: {h}; "); - } -} - -fn para_css(p: &ParaProps, base_size: f64, line_px: f64, out: &mut String) { - let align = match p.align { - Align::Left | Align::TowardsSpine => "left", - Align::Center => "center", - Align::Right | Align::AwayFromSpine => "right", - _ => "justify", - }; - let _ = write!(out, "text-align: {align}; "); - let em = |v: f64| v / base_size; - let _ = write!( - out, - "margin: {:.3}em {:.3}em {:.3}em {:.3}em; text-indent: {:.3}em; line-height: {:.3}; ", - em(p.space_before), - em(p.right_indent), - em(p.space_after), - em(p.left_indent), - em(p.first_line_indent), - line_px - ); - out.push_str(if p.hyphenate { "hyphens: auto; -webkit-hyphens: auto; " } else { "hyphens: manual; " }); -} - -/// The stylesheet for all paragraph and character styles. -pub fn stylesheet(doc: &Document) -> String { - stylesheet_with(doc, &BTreeMap::new(), &[]) -} - -/// [`stylesheet`] with the generic families to fall back on and `@font-face` rules for the -/// embedded fonts (packaged as [`font_file`] names them). -fn stylesheet_with(doc: &Document, generics: &BTreeMap, fonts: &[EmbeddedFont]) -> String { - let st: &Styles = &doc.styles; - let mut faces = String::new(); - for (k, f) in fonts.iter().enumerate() { - let (path, _) = font_file(k, f); - let (weight, style) = css_weight_style(&f.style); - let _ = writeln!( - faces, - "@font-face {{ font-family: \"{}\"; src: url(\"{path}\"); font-weight: {weight}; font-style: {style}; }}", - css_string(&f.family) - ); - } - let (_, basic) = st.resolve_para_style(story::BASIC_PARAGRAPH); - let base = basic.size.max(1.0); - let mut css = faces; - css.push_str( - "body { margin: 0 5%; font-size: 1em; }\nfigure { margin: 1em 0; text-align: center; }\nfigure img { max-width: 100%; }\nh1, h2, h3 { font-size: inherit; margin: 0; }\n", - ); - let (pc, cc) = (para_classes(st), char_classes(st)); - for ps in &st.paragraph { - if ps.name == designcraft_doc::NO_PARA_STYLE { - continue; - } - let (pp, cp) = st.resolve_para_style(&ps.name); - let lead = match cp.leading { - designcraft_doc::Leading::Auto => pp.auto_leading, - designcraft_doc::Leading::Points(v) => v / cp.size.max(1.0), - }; - let mut rule = String::new(); - para_css(&pp, base, lead, &mut rule); - char_css(doc, &cp, base, generics, &mut rule); - let (tag, class) = export_mapping(st, &ps.name, false, &pc); - if let Some(selector) = class_selector(tag, &class) { - let _ = writeln!(css, "{selector} {{ {rule}}}"); - } - } - for cs in &st.character { - if cs.name == story::NO_CHAR_STYLE { - continue; - } - let mut rule = String::new(); - let c = &cs.chars; - if let Some(f) = &c.font_style { - let f = f.to_lowercase(); - if f.contains("bold") { - rule.push_str("font-weight: bold; "); - } - if f.contains("italic") { - rule.push_str("font-style: italic; "); - } - } - if let Some(fill) = &c.fill - && let Some(h) = hex(doc, fill, c.fill_tint.unwrap_or(1.0)) - { - let _ = write!(rule, "color: {h}; "); - } - if let Some(sz) = c.size { - let _ = write!(rule, "font-size: {:.3}em; ", sz / base); - } - let (tag, class) = export_mapping(st, &cs.name, true, &cc); - if let Some(selector) = class_selector(tag, &class) { - let _ = writeln!(css, "{selector} {{ {rule}}}"); - } - } - css -} - -fn override_css(doc: &Document, o: &CharAttrs) -> String { - let mut s = String::new(); - if let Some(f) = &o.font_style { - let f = f.to_lowercase(); - if f.contains("bold") { - s.push_str("font-weight: bold; "); - } - if f.contains("italic") { - s.push_str("font-style: italic; "); - } - } - if let Some(fill) = &o.fill - && let Some(h) = hex(doc, fill, o.fill_tint.unwrap_or(1.0)) - { - let _ = write!(s, "color: {h}; "); - } - if o.underline == Some(true) { - s.push_str("text-decoration: underline; "); - } - if o.capitalization == Some(designcraft_doc::Capitalization::AllCaps) { - s.push_str("text-transform: uppercase; "); - } - s.trim_end().to_string() -} - -/// Elements Export Tagging may choose (paragraph or character level). -fn is_tag(t: &str, character: bool) -> bool { - if character { - matches!(t, "span" | "em" | "strong" | "i" | "b" | "code" | "sup" | "sub" | "small" | "mark" | "cite" | "q") - } else { - matches!(t, "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote" | "pre" | "li" | "figcaption" | "aside" | "div") - } -} - -/// Resolve Export Tagging once for both markup and its stylesheet. Keep the generated, -/// collision-free class when no custom class was supplied, and the existing tag fallback. -fn export_mapping<'a>(st: &'a Styles, name: &str, character: bool, classes: &BTreeMap<&str, String>) -> (&'a str, String) { - let et = st.export_tag(name, character); - let tag = et.map(|e| e.tag.as_str()).filter(|t| is_tag(t, character)).unwrap_or(if character { "span" } else { "p" }); - let class = et.map(|e| e.class.clone()).filter(|c| !c.is_empty()).or_else(|| classes.get(name).cloned()).unwrap_or_else(|| slug(name)); - // Match the characters xml_attr actually emits, before escaping either output context. - let class = class.chars().filter(|c| valid_xml_char(*c) && !('\u{E000}'..='\u{E1FF}').contains(c)).collect(); - (tag, class) -} - -/// Each class token must match on the same element. Escape CSS identifier punctuation and -/// leading digits without changing the class written into XHTML. -fn class_selector(tag: &str, class: &str) -> Option { - let mut tokens = class.split_ascii_whitespace().peekable(); - // A nonempty but whitespace-only custom class has no token to select. Never emit a bare - // element selector, which would apply this style to unrelated paragraphs or character runs. - tokens.peek()?; - let mut selector = tag.to_string(); - for token in tokens { - selector.push('.'); - for (i, c) in token.chars().enumerate() { - let leading_digit = c.is_ascii_digit() && (i == 0 || i == 1 && token.starts_with('-')); - if !leading_digit && token != "-" && (c.is_ascii_alphanumeric() || matches!(c, '-' | '_') || !c.is_ascii()) { - selector.push(c); - } else { - let _ = write!(selector, "\\{:x} ", c as u32); - } - } - } - Some(selector) -} - -/// Closes the open list, if any. -fn close_list(list: &mut Option<&str>, out: &mut String) { - if let Some(l) = list.take() { - let _ = writeln!(out, "", l.split(' ').next().unwrap_or(l)); - } -} - -/// Closes the open preformatted block and list, if any. -fn close_blocks(list: &mut Option<&str>, pre: &mut Option<(String, bool)>, out: &mut String) { - if pre.take().is_some() { - out.push_str("\n"); - } - close_list(list, out); -} - -/// Nesting limit for stories inside stories (table cells, footnotes, tables in cells), so a deeply -/// nested or cyclic document can't recurse without bound. -const MAX_DEPTH: usize = 8; - -/// Writes stories with their tables and notes, numbering notes across the stories of one export. -struct StoryWriter<'a, 'f> { - doc: &'a Document, - pc: BTreeMap<&'a str, String>, - cc: BTreeMap<&'a str, String>, - /// EPUB structural semantics (`epub:type`); plain HTML doesn't declare the namespace. - epub: bool, - /// Notes written so far, for unique element ids. - footnote_ids: u32, - endnote_ids: u32, - /// Number shown on the next footnote and endnote of the current story. - next_footnote: u32, - next_endnote: u32, - /// Endnotes met so far: (number, list item markup). - endnotes: Vec<(u32, String)>, - /// Names each packaged file (a package path, or a data URI for single-file HTML). Without - /// it, anchored objects are left out. - img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>, - /// Images, video and sound met so far, by package name. - images: BTreeMap, - /// Asset names of the images written without alt text (decorative). - no_alt: Vec, - /// Prefix of the `id`s the current story's headings get (`{prefix}-p{paragraph index}`, the - /// targets of the table of contents); only top-level paragraphs get one. - heading_ids: Option, -} - -impl<'a, 'f> StoryWriter<'a, 'f> { - fn new(doc: &'a Document, epub: bool, img_src: Option<&'f dyn Fn(&str, &str, &[u8]) -> String>) -> Self { - StoryWriter { - doc, - pc: para_classes(&doc.styles), - cc: char_classes(&doc.styles), - epub, - footnote_ids: 0, - endnote_ids: 0, - next_footnote: 1, - next_endnote: 1, - endnotes: Vec::new(), - img_src, - images: BTreeMap::new(), - no_alt: Vec::new(), - heading_ids: None, - } - } - - fn epub_type(&self, t: &str) -> String { - if self.epub { format!(" epub:type=\"{t}\"") } else { String::new() } - } - - /// One document story: paragraphs, tables, and each footnote after the paragraph that refers - /// to it. - fn story(&mut self, sid: StoryId, heading_ids: Option<&str>) -> String { - let Some(st) = self.doc.story(sid) else { return String::new() }; - self.heading_ids = heading_ids.map(str::to_string); - self.next_footnote = self.doc.footnote_start(sid); - self.next_endnote = self.doc.endnote_start(sid); - let mut out = String::new(); - self.paragraphs(st, 0, "", &mut out); - out - } - - /// `st`'s paragraphs. `lead` (a note's number) starts the first paragraph. - /// - /// Consecutive `li` paragraphs share one list (`ol` for a numbered style, `ul` for a bulleted - /// one, an unmarked `ul` when the style has no list type of its own, e.g. typed bullets), and - /// consecutive `pre` paragraphs of one style one block, their lines separated by newlines. - fn paragraphs(&mut self, st: &Story, depth: usize, lead: &str, out: &mut String) { - let doc = self.doc; - let mut lead = lead; - let mut list: Option<&str> = None; - let mut pre: Option<(String, bool)> = None; - for (pi, (r, pf)) in st.para_ranges().into_iter().zip(&st.paras).enumerate() { - let Some(text) = st.text.get(r.clone()) else { continue }; - let table = pf.table.and_then(|id| st.tables.get(&id)).filter(|_| text.contains(story::TABLE_ANCHOR)); - let only_table = table.is_some() && text.chars().all(|c| c == story::TABLE_ANCHOR || c.is_whitespace()); - if !only_table || !lead.is_empty() { - // Export Tagging: the style's element and class. - let (tag, class) = export_mapping(&doc.styles, &pf.style, false, &self.pc); - let props = doc.styles.resolve_para(pf).0; - let rtl = props.direction == designcraft_doc::TextDirection::RightToLeft; - let continues_pre = tag == "pre" && pre.as_ref() == Some(&(class.clone(), rtl)); - if !continues_pre && pre.take().is_some() { - out.push_str("\n"); - } - let list_tag = (tag == "li").then_some(match props.list_type { - designcraft_doc::ListType::Numbers => "ol", - designcraft_doc::ListType::Bullets => "ul", - designcraft_doc::ListType::None => "ul style=\"list-style:none\"", - }); - if list != list_tag { - close_list(&mut list, out); - if let Some(l) = list_tag { - let _ = writeln!(out, "<{l}>"); - } - list = list_tag; - } - // An EPUB chapter file starts before each top-level h1, outside any open list or - // preformatted block. - if depth == 0 && self.heading_ids.is_some() && heading_level(tag) == Some(1) { - out.push_str(CHAPTER_MARK); - } - if continues_pre { - out.push('\n'); - } else { - let id = match &self.heading_ids { - Some(prefix) if depth == 0 && heading_level(tag).is_some() => format!(" id=\"{prefix}-p{pi}\""), - _ => String::new(), - }; - let _ = write!(out, "<{tag}{id} class=\"{}\"{}>", xml_attr(&class), if rtl { " dir=\"rtl\"" } else { "" }); - } - out.push_str(lead); - if lead.is_empty() && text.trim().is_empty() { - out.push_str(" "); - } - lead = ""; - let mut after = String::new(); - if !only_table { - self.runs(st, r, depth, out, &mut after); - } - if tag == "pre" { - // Closed when the next paragraph isn't a line of the same block. - pre = Some((class, rtl)); - } else { - let _ = writeln!(out, ""); - } - // Footnotes follow the paragraph outside any list or preformatted block. - if !after.is_empty() { - close_blocks(&mut list, &mut pre, out); - out.push_str(&after); - } - } - if let Some(t) = table - && depth < MAX_DEPTH - { - close_blocks(&mut list, &mut pre, out); - self.table(t, depth + 1, out); - } - } - close_blocks(&mut list, &mut pre, out); - } - - /// The markup for one run's text `raw` (starting at byte `a` of the story): anchored objects - /// and notes where their marks are. - fn run_markup(&mut self, st: &Story, raw: &str, a: usize, depth: usize, after: &mut String) -> String { - let doc = self.doc; - let mut t = String::new(); - let mut seg_start = 0; - // Index of the next anchored object: marks count from the story start, as - // `Story::object_at` counts them (counted once, at the run's first mark). - let mut mark: Option = None; - for (i, c) in raw.char_indices() { - if c != FOOTNOTE_REF && c != ENDNOTE_REF && c != OBJECT_MARK { - continue; - } - t.push_str(&run_text(doc, raw.get(seg_start..i).unwrap_or_default())); - seg_start = i + c.len_utf8(); - if c == OBJECT_MARK { - let k = mark.unwrap_or_else(|| st.text.get(..a).map_or(0, |t| t.matches(OBJECT_MARK).count())); - mark = Some(k.saturating_add(1)); - if let Some(o) = st.objects.get(k) { - t.push_str(&self.object(o)); - } - } else if depth < MAX_DEPTH { - if c == FOOTNOTE_REF { - self.footnote(st, a + i, depth, &mut t, after); - } else { - self.endnote(st, a + i, depth, &mut t); - } - } - } - t.push_str(&run_text(doc, raw.get(seg_start..).unwrap_or_default())); - t - } - - /// The runs of paragraph `para` of `st`; footnotes go to `after`. - fn runs(&mut self, st: &Story, para: std::ops::Range, depth: usize, out: &mut String, after: &mut String) { - let doc = self.doc; - for (rr, f) in st.runs() { - let a = rr.start.max(para.start); - let b = rr.end.min(para.end); - if a >= b { - continue; - } - // Hidden conditional text isn't exported. - if f.over.conditions.as_deref().is_some_and(|c| doc.conditions_hide(c)) || f.over.change == Some(designcraft_doc::ChangeMark::Deleted) { - continue; - } - let Some(raw) = st.text.get(a..b) else { continue }; - let t = self.run_markup(st, raw, a, depth, after); - let (ctag, cls) = if f.style != story::NO_CHAR_STYLE { - let (tag, class) = export_mapping(&doc.styles, &f.style, true, &self.cc); - (tag, format!(" class=\"{}\"", xml_attr(&class))) - } else { - ("span", String::new()) - }; - let style = override_css(doc, &f.over); - let style_attr = if style.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&style)) }; - if cls.is_empty() && style_attr.is_empty() { - out.push_str(&t); - } else { - let _ = write!(out, "<{ctag}{cls}{style_attr}>{t}"); - } - } - } - - /// An anchored object where it is anchored: its graphics in the text, an above-line object as - /// a block of its own. Anchored text frames are left out. - fn object(&mut self, o: &AnchoredObject) -> String { - let mut out = String::new(); - o.item.walk(&mut |it| { - if let Some((markup, _)) = self.graphic(it) { - out.push_str(&markup); - } - }); - match &o.position { - AnchorPosition::AboveLine { align, .. } if !out.is_empty() => { - let align = match align { - designcraft_doc::anchored::AnchorAlign::Left => "left", - designcraft_doc::anchored::AnchorAlign::Center => "center", - designcraft_doc::anchored::AnchorAlign::Right => "right", - }; - format!("{out}") - } - _ => out, - } - } - - /// The markup for one graphic item: an ``, or an HTML5 player for video and sound (with - /// `true`), its file registered in `images`. `None` for an item that isn't a graphic, whose - /// asset has no data, or when the writer names no files. - fn graphic(&mut self, it: &Item) -> Option<(String, bool)> { - let img_src = self.img_src?; - let doc = self.doc; - let Content::Graphic(g) = &it.content else { return None }; - let a = doc.assets.get(&g.asset)?; - if a.data.is_empty() { - return None; - } - let mime = safe_mime(&a.mime); - // Video and sound: an HTML5 player (with the poster, when there is one). - if let Some(kind) = designcraft_doc::media_kind(mime) { - let name = asset_path("media", a, mime); - self.images.insert(name.clone(), (mime, a.data.as_slice())); - let m = it.media.clone().unwrap_or_default(); - let mut attrs = String::new(); - for (on, attr) in [(m.controls, " controls=\"controls\""), (m.play_on_page_load, " autoplay=\"autoplay\""), (m.looping, " loop=\"loop\"")] - { - if on { - attrs.push_str(attr); - } - } - if let Some(pa) = m.poster.and_then(|p| doc.assets.get(&p)) { - let poster_mime = safe_mime(&pa.mime); - let pn = asset_path("images", pa, poster_mime); - self.images.insert(pn.clone(), (poster_mime, pa.data.as_slice())); - if kind == "video" { - attrs.push_str(&format!(" poster=\"{}\"", xml_attr(&img_src(&pn, poster_mime, &pa.data)))); - } - } - let markup = format!("<{kind} src=\"{}\"{attrs}>{}", xml_attr(&img_src(&name, mime, &a.data)), xml_text(&a.name)); - return Some((markup, true)); - } - let name = asset_path("images", a, mime); - self.images.insert(name.clone(), (mime, a.data.as_slice())); - // Alt text from Object Export Options; without one the image counts as decorative - // (`alt=""`): a file name read aloud helps nobody. - if it.alt_text.trim().is_empty() { - self.no_alt.push(a.name.clone()); - } - Some((format!("\"{}\"/", xml_attr(&img_src(&name, mime, &a.data)), xml_attr(&it.alt_text)), false)) - } - - /// The footnote whose reference is at byte `pos` of `st`: a note reference in `out`, the note - /// itself in `after`. - fn footnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String, after: &mut String) { - let Some(note) = st.notes.get(st.notes_before(pos)) else { return }; - let doc = self.doc; - let o = &doc.footnote_options; - let n = self.next_footnote; - self.next_footnote = n.saturating_add(1); - self.footnote_ids = self.footnote_ids.saturating_add(1); - let id = self.footnote_ids; - let _ = write!( - out, - "{}", - self.epub_type("noteref"), - xml_text(&o.label(n, false)) - ); - let lead = format!("{} ", xml_text(&o.label(n, true))); - let mut body = String::new(); - self.paragraphs(¬e.text, depth + 1, &lead, &mut body); - let _ = write!(after, "\n{body}\n", self.epub_type("footnote")); - } - - /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note - /// joins the endnotes section. - fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { - let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; - let n = self.next_endnote; - self.next_endnote = n.saturating_add(1); - self.endnote_ids = self.endnote_ids.saturating_add(1); - let id = self.endnote_ids; - let label = xml_text(&self.doc.endnote_options.label(n)); - let _ = write!(out, "{label}", self.epub_type("noteref")); - let mut body = String::new(); - self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); - let item = format!("\n{body}\n", self.epub_type("endnote")); - self.endnotes.push((n, item)); - } - - /// The endnotes met so far, in number order, as one section (empty when there are none). - fn endnotes_section(&mut self) -> String { - if self.endnotes.is_empty() { - return String::new(); - } - self.endnotes.sort_by_key(|(n, _)| *n); - let doc = self.doc; - let o = &doc.endnote_options; - let mut out = format!("\n", self.epub_type("endnotes")); - if !o.heading.trim().is_empty() { - let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); - let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); - } - out.push_str("
      \n"); - for (_, item) in &self.endnotes { - out.push_str(item); - } - out.push_str("
    \n\n"); - out - } - - /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column - /// spans (kept within their row group). - fn table(&mut self, t: &Table, depth: usize, out: &mut String) { - let (nr, nc) = (t.nrows(), t.ncols()); - if nr.checked_mul(nc) != Some(t.cells.len()) { - return; - } - let (h, f) = (t.header_rows(), t.footer_rows()); - let body_end = nr.saturating_sub(f).max(h); - let mut covered = vec![false; t.cells.len()]; - out.push_str("
    \n"); - for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { - if rows.is_empty() { - continue; - } - let _ = writeln!(out, "<{group}>"); - for r in rows.clone() { - out.push_str(""); - for c in 0..nc { - let i = r * nc + c; - if covered.get(i).copied().unwrap_or(true) { - continue; - } - let Some(cell) = t.cells.get(i) else { continue }; - let rs = (cell.row_span.max(1) as usize).min(rows.end - r); - let cs = (cell.col_span.max(1) as usize).min(nc - c); - for rr in r..r + rs { - for cc in c..c + cs { - if let Some(slot) = covered.get_mut(rr * nc + cc) { - *slot = true; - } - } - } - let _ = write!(out, "<{cell_tag}"); - if rs > 1 { - let _ = write!(out, " rowspan=\"{rs}\""); - } - if cs > 1 { - let _ = write!(out, " colspan=\"{cs}\""); - } - out.push('>'); - self.paragraphs(&cell.text, depth, "", out); - let _ = writeln!(out, ""); - } - out.push_str("\n"); - } - let _ = writeln!(out, ""); - } - out.push_str("
    \n"); - } -} - -/// Run text with variables replaced by their values. -fn run_text(doc: &Document, raw: &str) -> String { - if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { - let sub: String = raw - .chars() - .map(|c| match designcraft_doc::vars::var_index(c) { - Some(i) => doc.variable_value(i, None).unwrap_or_default(), - None => c.to_string(), - }) - .collect(); - story_markup(&sub) - } else { - story_markup(raw) - } -} - -/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to -/// it. Endnote references link to the endnotes section that the full export ends with. -pub fn story_html(doc: &Document, sid: StoryId) -> String { - StoryWriter::new(doc, true, None).story(sid, None) -} - -/// Where the EPUB body splits into chapter files: before each `h1`. -const CHAPTER_MARK: &str = ""; - -/// The body split at its [`CHAPTER_MARK`]s into chapter bodies, each with its sections closed -/// and the one a split goes through reopened in the next; a part with nothing but the start of a -/// section (a story that opens with its `h1`) moves that start into the next part. -fn split_chapters(body: &str) -> Vec { - let mut out: Vec = Vec::new(); - let mut carry: Option = None; - for piece in body.split(CHAPTER_MARK) { - let mut part = carry.take().unwrap_or_default(); - part.push_str(piece); - if let Some(open) = part.rfind("")) { - let tag_end = part[open..].find('>').map_or(part.len(), |e| open + e + 1); - if part[tag_end..].trim().is_empty() { - carry = Some(part[open..].to_string()); - part.truncate(open); - } else { - part.push_str("\n"); - carry = Some("
    \n".to_string()); - } - } - if !part.trim().is_empty() { - out.push(part); - } - } - if let Some(rest) = carry.filter(|c| !c.trim().is_empty()) - && let Some(last) = out.last_mut() - { - // Only an opening tag can be left over; close it where it stands. - last.push_str(&rest); - last.push_str("
    \n"); - } - if out.is_empty() { - out.push(String::new()); - } - out -} - -/// `chapter` (the file `own`) with each in-document link (`href="#id"`) to an element in another -/// chapter file pointed at that file. -fn link_across_chapters(chapter: &str, own: &str, files: &BTreeMap) -> String { - const HREF: &str = "href=\"#"; - let mut out = String::with_capacity(chapter.len()); - let mut rest = chapter; - while let Some(i) = rest.find(HREF) { - out.push_str(rest.get(..i).unwrap_or_default()); - let tail = rest.get(i + HREF.len()..).unwrap_or_default(); - let id = tail.split('"').next().unwrap_or_default(); - match files.get(id) { - Some(file) if file != own => { - let _ = write!(out, "href=\"{file}#"); - } - _ => out.push_str(HREF), - } - rest = tail; - } - out.push_str(rest); - out -} - -/// The package file of chapter `k` (0-based): `content.xhtml`, then `content2.xhtml`, …. -fn chapter_file(k: usize) -> String { - if k == 0 { "content.xhtml".to_string() } else { format!("content{}.xhtml", k + 1) } -} - -/// The level of a heading element (`h1` → 1 … `h6` → 6). -fn heading_level(tag: &str) -> Option { - tag.strip_prefix('h').and_then(|n| n.parse::().ok()).filter(|n| (1..=6).contains(n)) -} - -/// Table of contents entries (target, label, level): the `h1`–`h3` paragraphs of the stories in -/// reading order, or, in a document without headings, each story's first paragraph (level 1). -fn toc_entries(doc: &Document, order: &[Block]) -> Vec<(String, String, u8)> { - let pc = para_classes(&doc.styles); - let label = |t: &str| -> String { t.chars().filter(|c| !('\u{E000}'..='\u{E0FF}').contains(c)).take(80).collect::().trim().to_string() }; - let mut headings = Vec::new(); - let mut firsts = Vec::new(); - for (k, b) in order.iter().enumerate() { - let Block::Story(sid) = b else { continue }; - // The endnote story isn't written (the endnotes section stands in for it). - if doc.endnote_story == Some(*sid) { - continue; - } - let Some(st) = doc.story(*sid) else { continue }; - let first = label(st.text.split('\n').next().unwrap_or("")); - if !first.is_empty() { - firsts.push((format!("s{k}"), first, 1)); - } - for (pi, r) in st.para_ranges().iter().enumerate() { - let Some(pf) = st.paras.get(pi) else { continue }; - let (tag, _) = export_mapping(&doc.styles, &pf.style, false, &pc); - let Some(level) = heading_level(tag).filter(|l| *l <= 3) else { continue }; - let text = label(st.text.get(r.clone()).unwrap_or("")); - if !text.is_empty() { - headings.push((format!("s{k}-p{pi}"), text, level)); - } - } - } - if headings.is_empty() { firsts } else { headings } -} - -/// The table of contents as nested `li`/`ol` items (the content of the navigation's outer -/// `ol`): a deeper level goes into the previous item, skipped levels count as one. -fn nav_list(entries: &[(String, String, u8)], files: &BTreeMap) -> String { - let mut out = String::new(); - let mut open: Vec = Vec::new(); - for (target, label, level) in entries { - let file = files.get(target).map_or("content.xhtml", String::as_str); - let item = format!("
  • {}", xml_text(label)); - match open.last().copied() { - None => open.push(*level), - Some(top) if *level > top => { - out.push_str("
      "); - open.push(*level); - } - Some(_) => { - // Close the levels whose parent is as deep as this entry or deeper. - while open.len() > 1 && open.len().checked_sub(2).and_then(|i| open.get(i)).is_some_and(|parent| *parent >= *level) { - open.pop(); - out.push_str("
    "); - } - out.push_str("
  • "); - if let Some(t) = open.last_mut() { - *t = (*t).min(*level); - } - } - } - out.push_str(&item); - } - for i in (0..open.len()).rev() { - out.push_str(""); - if i > 0 { - out.push_str(""); - } - } - out -} - -/// Reading order of document content: (page, y, x) of each story's first frame and of graphics. -#[derive(Debug, Clone, PartialEq)] -pub enum Block { - Story(StoryId), - Image(ItemId), -} - -pub fn reading_order(doc: &Document) -> Vec { - // Articles, when there are any to export, decide the order (and what's included). - if doc.articles.iter().any(|a| a.export) { - let mut out = Vec::new(); - let mut seen = Vec::new(); - for a in doc.articles.iter().filter(|a| a.export) { - for id in &a.items { - let Some(top) = doc.item(*id) else { continue }; - let mut push = |it: &Item| match &it.content { - Content::Text(tf) if !seen.contains(&tf.story) => { - seen.push(tf.story); - out.push(Block::Story(tf.story)); - } - Content::Graphic(_) => out.push(Block::Image(it.id)), - _ => {} - }; - top.walk(&mut |i| push(i)); - } - } - return out; - } - let mut keyed: Vec<((usize, i64, i64), Block)> = Vec::new(); - let mut seen_stories = Vec::new(); - for (si, sp) in doc.spreads.iter().enumerate() { - let first = doc.first_page_of_spread(si); - // Preserve traversal order while carrying each group's transform to spread space. - // The iterator stack grows with nesting depth, without repeated document lookups. - let mut stack = vec![(sp.items.iter(), Affine::IDENTITY)]; - while let Some((items, parent)) = stack.last_mut() { - let Some(it) = items.next() else { - stack.pop(); - continue; - }; - let xf = *parent * it.xf; - let children = it.children(); - if !children.is_empty() { - stack.push((children.iter(), xf)); - } - if it.hidden { - continue; - } - let block = match &it.content { - Content::Text(tf) => { - let Some(story) = doc.story(tf.story) else { continue }; - // A story is placed where its first frame is. - if story.frames.first() != Some(&it.id) || seen_stories.contains(&tf.story) || story.text.trim().is_empty() { - continue; - } - seen_stories.push(tf.story); - Block::Story(tf.story) - } - Content::Graphic(_) => Block::Image(it.id), - _ => continue, - }; - // Transform the path once: rotating a local axis-aligned box again would - // enlarge it and give a different top-left from the actual spread geometry. - let b = it.path.transformed(xf).bounds().unwrap_or(Rect::ZERO); - let page = first + sp.page_at_x(b.center().x).unwrap_or(0); - let key = (page, (b.y0 * 10.0) as i64, (b.x0 * 10.0) as i64); - keyed.push((key, block)); - } - } - let _ = SpreadRef::Doc(0); - keyed.sort_by_key(|(k, _)| *k); - keyed.into_iter().map(|(_, b)| b).collect() -} - -/// Package path of an asset: `{dir}/{name}-{id}.{ext}`, the name from its original file name -/// (lowercase ASCII, so the package stays readable) and the id keeping two assets of the same -/// name apart. -fn asset_path(dir: &str, a: &designcraft_doc::Asset, mime: &str) -> String { - let stem = a.name.rsplit(['/', '\\']).next().unwrap_or(""); - let stem = stem.rsplit_once('.').map_or(stem, |(s, _)| s); - let name: String = stem.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let name: String = name.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(40).collect(); - if name.is_empty() { format!("{dir}/{}.{}", a.id.0, ext(mime)) } else { format!("{dir}/{name}-{}.{}", a.id.0, ext(mime)) } -} - -fn ext(mime: &str) -> &'static str { - match mime { - "image/jpeg" => "jpg", - "image/gif" => "gif", - "image/webp" => "webp", - "image/svg+xml" => "svg", - "video/mp4" => "mp4", - "video/quicktime" => "mov", - "video/webm" => "webm", - "video/ogg" => "ogv", - "audio/mpeg" => "mp3", - "audio/mp4" => "m4a", - "audio/wav" => "wav", - "audio/ogg" => "ogg", - "audio/aac" => "aac", - _ => "png", - } -} - -/// The document content as HTML, with the files it names. -struct Body<'a> { - html: String, - /// Images, video and sound by package name: (media type, bytes). - images: BTreeMap, - /// Table of contents: (element id, label, level). - toc: Vec<(String, String, u8)>, - /// Asset names of the images written without alt text. - no_alt: Vec, -} - -/// The content in reading order as HTML sections and figures; `img_src` names each image -/// (a package path, or a data URI for single-file HTML). -fn body_html<'a>(doc: &'a Document, epub: bool, img_src: &dyn Fn(&str, &str, &[u8]) -> String) -> Body<'a> { - let order = reading_order(doc); - let mut writer = StoryWriter::new(doc, epub, Some(img_src)); - let mut body = String::new(); - let mut toc = toc_entries(doc, &order); - for (k, b) in order.iter().enumerate() { - match b { - // The endnote frame's text is generated from the endnotes, which end the body as a - // linked section instead. - Block::Story(sid) if doc.endnote_story == Some(*sid) => {} - Block::Story(sid) => { - let anchor = format!("s{k}"); - let _ = write!(body, "
    \n{}
    \n", writer.story(*sid, epub.then_some(anchor.as_str()))); - } - Block::Image(iid) => { - let Some(it) = doc.item(*iid) else { continue }; - let Some((markup, media)) = writer.graphic(it) else { continue }; - if media { - let _ = writeln!(body, "
    {markup}
    "); - continue; - } - // Object Export Options: alignment and a page break before. - let eo = &it.export_options; - let mut css = String::new(); - if matches!(eo.align.as_str(), "left" | "center" | "right") { - css.push_str(&format!("text-align:{};", eo.align)); - } - if eo.page_break_before { - css.push_str("page-break-before:always;break-before:page;"); - } - let style = if css.is_empty() { String::new() } else { format!(" style=\"{}\"", xml_attr(&css)) }; - let _ = writeln!(body, "{markup}"); - } - } - } - let endnotes = writer.endnotes_section(); - if !endnotes.is_empty() { - let heading = &doc.endnote_options.heading; - if !heading.trim().is_empty() { - toc.push(("endnotes".into(), heading.clone(), 1)); - } - body.push_str(&endnotes); - } - Body { html: body, images: writer.images, toc, no_alt: writer.no_alt } -} - -/// Placed and anchored images (not video or sound) without alt text, by asset name: they are exported as -/// decorative (`alt=""`), which an export can report. -pub fn images_without_alt(doc: &Document) -> Vec { - body_html(doc, true, &|name, _, _| name.to_string()).no_alt -} - -/// Options for [`export_html`]. -#[derive(Clone, Debug, Default)] -pub struct HtmlOptions { - pub title: Option, - pub language: Option, - /// Generic CSS family per font family (see [`EpubOptions::generic_families`]). - pub generic_families: BTreeMap, -} - -/// File › Export › HTML: one self-contained page (styles inline, images as data URIs) with the -/// document's stories and graphics in reading order. -pub fn export_html(doc: &Document, opts: &HtmlOptions) -> String { - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let language = opts.language.as_deref().unwrap_or("en"); - let lang = language_tag(language); - let body = body_html(doc, false, &|_, mime, data| format!("data:{mime};base64,{}", base64(data))).html; - format!( - "\n\n\n\n\n\n{}\n\n\n\n{body}\n\n", - xml_text(&title), - stylesheet_with(doc, &opts.generic_families, &[]) - ) -} - -fn base64(data: &[u8]) -> String { - const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::with_capacity(data.len().div_ceil(3) * 4); - for c in data.chunks(3) { - let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; - for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() { - out.push(if i <= c.len() { T[(n >> shift & 63) as usize] as char } else { '=' }); - } - } - out -} - -/// Export the document as a reflowable EPUB 3. -pub fn export_epub(doc: &Document, opts: &EpubOptions) -> Result, EpubError> { - let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let id = identifier(doc, opts, &title); - let modified = modified(doc, opts); - let Body { html: body, images, toc, .. } = body_html(doc, true, &|name, _, _| name.to_string()); - let lang = language_tag(&opts.language); - // One file per chapter (split before each h1), so e-readers needn't load the whole book. - let chapters: Vec = split_chapters(&body) - .into_iter() - .map(|part| { - format!( - "\n\n\n{}\n\n{part}\n\n", - xml_text(&title) - ) - }) - .collect(); - // The file each navigation target (an element id) ended up in. - let mut files: BTreeMap = BTreeMap::new(); - for (k, c) in chapters.iter().enumerate() { - for rest in c.split(" id=\"").skip(1) { - if let Some(id) = rest.split('"').next() { - files.entry(id.to_string()).or_insert_with(|| chapter_file(k)); - } - } - } - // Note links whose target ended up in another file (endnotes end the book) point at it. - let chapters: Vec = chapters.iter().enumerate().map(|(k, c)| link_across_chapters(c, &chapter_file(k), &files)).collect(); - let mut nav_items = nav_list(&toc, &files); - if nav_items.is_empty() { - nav_items = format!("
  • {}
  • ", xml_text(&title)); - } - let nav = format!( - "\n\n\n{}\n\n\n", - xml_text(&title), - xml_text(opts.toc_title.as_deref().filter(|t| !t.trim().is_empty()).unwrap_or(contents_title(&opts.language))) - ); - let mut manifest = String::from( - "\n\n", - ); - let mut spine = String::new(); - for k in 0..chapters.len() { - let item = if k == 0 { "content".to_string() } else { format!("content{}", k + 1) }; - let _ = writeln!(manifest, "", chapter_file(k)); - let _ = writeln!(spine, ""); - } - for (k, (name, (mime, _))) in images.iter().enumerate() { - let _ = writeln!(manifest, "", xml_attr(name), xml_attr(mime)); - } - for (k, f) in opts.fonts.iter().enumerate() { - let (name, mime) = font_file(k, f); - let _ = writeln!(manifest, "", xml_attr(&name)); - } - // Cover: an image page first in reading order. - if let Some(c) = &opts.cover { - let (name, mime) = cover_file(c); - let _ = writeln!( - manifest, - "\n" - ); - } - let cover_spine = if opts.cover.is_some() { "\n" } else { "" }; - let head = PackageHead { lang, id: &id, title: &title, author: opts.author.as_deref(), modified: &modified }; - let opf = package_opf(&head, "", "", &manifest, &format!("{cover_spine}{spine}")); - let css = stylesheet_with(doc, &opts.generic_families, &opts.fonts); - let mut entries: Vec<(String, &[u8], bool)> = vec![("OEBPS/style.css".into(), css.as_bytes(), false)]; - entries.extend(chapters.iter().enumerate().map(|(k, c)| (format!("OEBPS/{}", chapter_file(k)), c.as_bytes(), false))); - entries.extend(images.iter().map(|(name, (_, data))| (format!("OEBPS/{name}"), *data, true))); - entries.extend(opts.fonts.iter().enumerate().map(|(k, f)| (format!("OEBPS/{}", font_file(k, f).0), f.data.as_slice(), false))); - let cover_xhtml = opts.cover.as_ref().map(|c| { - format!( - "\n\n\n{}\n\"{}\"/\n\n", - xml_text(&title), - cover_file(c).0, - xml_attr(&title) - ) - }); - if let (Some(c), Some(xhtml)) = (&opts.cover, &cover_xhtml) { - entries.push((format!("OEBPS/{}", cover_file(c).0), c.as_slice(), true)); - entries.push(("OEBPS/cover.xhtml".into(), xhtml.as_bytes(), false)); - } - write_package(&opf, &nav, entries) -} - #[cfg(test)] -mod tests { - use super::*; - use designcraft_doc::build::NewDocument; - use designcraft_doc::geom::{Affine, Rect, shapes}; - use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; - use std::io::Read; - use std::sync::Arc; - - #[test] - fn exports_valid_package_in_reading_order() { - let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); - let lid = d.default_layer(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second & co", ParaFormat::default()).unwrap(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap(); - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - assert_eq!(z.by_index(0).unwrap().name(), "mimetype"); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let a = html.find("First").unwrap(); - let b = html.find("Second <b> & co").unwrap(); - assert!(a < b, "reading order"); - assert!(html.contains("

    ")); - let mut css = String::new(); - z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); - assert!(css.contains("p.basic-paragraph")); - assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); - } - - /// #766: anchored graphics are exported where they are anchored (inline in the text, an - /// above-line object as a block of its own), and their images go into the package. - #[test] - fn anchored_objects_are_exported_in_place() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - page: 0, - id: aid, - name: "dot.png".into(), - mime: "image/png".into(), - link: None, - data: Arc::new(vec![7]), - pixels: Some((1, 1)), - ..Default::default() - }), - ); - let graphic = |d: &mut Document, alt: &str| { - let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); - item.alt_text = alt.into(); - item.content = Content::Graphic(Graphic { - asset: aid, - size: (1.0, 1.0), - xf: Affine::IDENTITY, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - clip: None, - wrap: Default::default(), - fill: Default::default(), - }); - item - }; - let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); - let above = designcraft_doc::AnchoredObject::new( - graphic(&mut d, "above"), - designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, - ); - let st = d.story_mut(sid).unwrap(); - st.insert_object(1, inline); - // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. - st.insert_object(7, above); - // An anchored image without alt text is decorative and reported, like a placed one. - let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); - let end = d.story(sid).unwrap().text.len(); - d.story_mut(sid).unwrap().insert_object(end, bare); - assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]); - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let img = |alt: &str| format!("\"{alt}\"/", aid.0); - assert!(html.contains(&format!("A{}B

    ", img("inline"))), "{html}"); - assert!(html.contains(&format!("C{}D", img("above"))), "{html}"); - assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package"); - let page = export_html(&d, &HtmlOptions::default()); - assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); - // story_html on its own still leaves objects out. - assert!(!story_html(&d, sid).contains(" = z.file_names().map(str::to_string).collect(); - (s, names) - }; - let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); - let mut a = Document::new(&NewDocument::default()); - a.title = "Same".into(); - a.created = 1_700_000_000; - let mut b = a.clone(); - b.created = 1_700_000_001; - let (oa, _) = opf(&a, &EpubOptions::default()); - let (oa2, _) = opf(&a, &EpubOptions::default()); - let (ob, _) = opf(&b, &EpubOptions::default()); - assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); - assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); - assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); - // Modified: the given time, else the document's save (or creation) time. - assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); - let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; - assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); - // A JPEG cover is packaged as such. - let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; - let (o, names) = opf(&a, &jpeg); - assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); - assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); - } - - fn st_typed(d: &mut Document, sid: StoryId) { - if let Some(st) = d.story_mut(sid) { - st.paras[8].style = "Typed".into(); - } - } - - /// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and - /// consecutive `pre` paragraphs of one style one block with their lines on separate lines. - #[test] - fn lists_and_preformatted_blocks_are_grouped() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - for (name, tag, list) in [ - ("Bullet", "li", Some(designcraft_doc::ListType::Bullets)), - ("Number", "li", Some(designcraft_doc::ListType::Numbers)), - ("Typed", "li", None), - ("Code", "pre", None), - ] { - let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() }; - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para, - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); - } - let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); - let st = d.story_mut(sid).unwrap(); - for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() { - st.paras[i + 1].style = style.into(); - } - let html = story_html(&d, sid); - let li = |c: &str, t: &str| format!("
  • {t}
  • "); - assert!( - html.contains(&format!( - "
      \n{}\n{}\n
    \n
      \n{}\n{}\n
    \n", - li("bullet", "first"), - li("bullet", "second"), - li("number", "one"), - li("number", "two") - )), - "{html}" - ); - assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); - assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); - } - - /// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, - /// with its title in the book's language; without headings, each story's first paragraph. - #[test] - fn navigation_follows_the_headings() { - let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); - let lid = d.default_layer(); - for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new() }); - } - let text = "One\nBody\nOne point one\nMore\nTwo\nText"; - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); - let st = d.story_mut(sid).unwrap(); - for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { - st.paras[i].style = style.into(); - } - // A story that starts with body text adds nothing to the table of contents. - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); - let read = |d: &Document, opts: &EpubOptions, path: &str| { - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let nl = EpubOptions { language: "nl".into(), ..Default::default() }; - let nav = read(&d, &nl, "OEBPS/nav.xhtml"); - let link = |f: &str, t: &str, l: &str| format!("{l}"); - // Each h1 starts a chapter file (#768). - let want = format!( - "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", - link("content.xhtml", "s0-p0", "One"), - link("content.xhtml", "s0-p2", "One point one"), - link("content2.xhtml", "s0-p4", "Two") - ); - assert!(nav.contains(&want), "{nav}"); - assert!(!nav.contains("Text after the figure"), "{nav}"); - let content = read(&d, &nl, "OEBPS/content.xhtml"); - assert!(content.contains("

    One

    ") && content.contains("

    In dit boek

    ")); - // Without headings: one entry per story, its first paragraph. - let mut plain = Document::new(&NewDocument::default()); - let lid = plain.default_layer(); - plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); - let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); - assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("content.xhtml", "s0", "Opening"))), "{nav}"); - // A heading shallower than the one before it but deeper than its parent stays under the - // parent: h1 A, h3 B, h2 C nest B and C under A. - let files: BTreeMap = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect(); - let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); - assert_eq!( - nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files), - format!( - "
  • {}
    1. {}
    2. {}
  • {}
  • ", - link("A", "A", "A"), - link("B", "B", "B"), - link("C", "C", "C"), - link("D", "D", "D") - ) - ); - assert_eq!( - nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files), - format!( - "
  • {}
    1. {}
      1. {}
  • {}
  • ", - link("A", "A", "A"), - link("B", "B", "B"), - link("C", "C", "C"), - link("D", "D", "D") - ) - ); - } - - /// #768: the book is split into one file per chapter, before each h1, with well-formed - /// sections; the spine and manifest list the files in order. - #[test] - fn chapters_are_split_at_h1() { - let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); - let lid = d.default_layer(); - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: "Chapter".into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs::default(), - shortcut: String::new(), - }); - d.styles_mut().export_tags.insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new() }); - // A title page without a heading, then a story that opens with a chapter and has a - // second one in the middle. - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); - let (_, sid) = d - .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) - .unwrap(); - let st = d.story_mut(sid).unwrap(); - st.paras[0].style = "Chapter".into(); - st.paras[2].style = "Chapter".into(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); - let mut read = |path: &str| { - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); - assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}"); - assert!(c2.contains("
    \n

    \n

    ").count(), "{c}"); - assert!(!c.contains(CHAPTER_MARK)); - } - let opf = read("OEBPS/content.opf"); - assert!(opf.contains("\n\n\n"), "{opf}"); - assert!(opf.contains(""), "{last}"); - } - - #[test] - fn split_chapters_keeps_sections_well_formed() { - let m = CHAPTER_MARK; - assert_eq!(split_chapters("
    \n

    a

    \n
    \n"), vec!["
    \n

    a

    \n
    \n"]); - assert_eq!( - split_chapters(&format!("
    \n{m}

    A

    \n
    \n")), - vec!["
    \n

    A

    \n
    \n"] - ); - assert_eq!( - split_chapters(&format!("
    \n

    a

    \n{m}

    B

    \n
    \n")), - vec!["
    \n

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] - ); - assert_eq!(split_chapters(""), vec![String::new()]); - } - - #[test] - fn styles_whose_names_slug_alike_keep_their_own_classes() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] { - d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { - name: name.into(), - based_on: None, - next_style: None, - para: Default::default(), - chars: CharAttrs { size: Some(size), ..Default::default() }, - shortcut: String::new(), - }); - } - for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() { - let y = 36.0 + 100.0 * k as f64; - let pf = ParaFormat { style: name.into(), ..Default::default() }; - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap(); - } - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let mut css = String::new(); - z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); - let class_of = |text: &str| { - let end = html.find(&format!("\">{text}

    ")).unwrap(); - let start = html[..end].rfind("class=\"").unwrap() + 7; - html[start..end].to_string() - }; - let classes: Vec = (0..4).map(|k| class_of(&format!("P{k}"))).collect(); - for (k, c) in classes.iter().enumerate() { - assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}"); - assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}"); - } - } - - #[test] - fn slugs() { - assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph"); - assert_eq!(slug("Body First"), "body-first"); - assert_eq!(slug("1 Head"), "s-1-head"); - } - - #[test] - fn html_is_one_self_contained_page() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); - let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() }); - assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); - assert!(html.contains("p.basic-paragraph"), "styles inline"); - assert_eq!(base64(b"Man"), "TWFu"); - assert_eq!(base64(b"Ma"), "TWE="); - assert_eq!(base64(b"M"), "TQ=="); - } - - /// #774: an image without alt text is decorative (`alt=""`) and reported, and package names - /// come from the original file names. - #[test] - fn images_get_readable_names_and_no_file_name_alt() { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - page: 0, - id: aid, - name: name.into(), - mime: mime.into(), - link: None, - data: Arc::new(vec![1]), - pixels: Some((1, 1)), - ..Default::default() - }), - ); - let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); - item.alt_text = alt.into(); - item.content = Content::Graphic(Graphic { - asset: aid, - size: (1.0, 1.0), - xf: Affine::IDENTITY, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - clip: None, - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - aid.0 - }; - let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); - let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); - let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); - assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); - let mut x = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); - assert!(x.contains(&format!("\"\"/")), "{x}"); - assert!(x.contains(&format!("\"A")), "{x}"); - assert!(x.contains(&format!("\"Mark\"/")), "{x}"); - assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); - } - - /// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. - #[test] - fn generic_fallbacks_and_embedded_fonts() { - let none = BTreeMap::new(); - for (family, want) in [ - ("Source Serif 4", "serif"), - ("Source Sans 3", "sans-serif"), - ("Microsoft Sans Serif", "sans-serif"), - ("JetBrains Mono", "monospace"), - ("Courier New", "monospace"), - ("Franklin Gothic", "sans-serif"), - ("Garamond", "serif"), - ] { - assert_eq!(generic_family(family, &none), want, "{family}"); - } - let known: BTreeMap = - [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); - assert_eq!(generic_family("Montserrat", &known), "sans-serif"); - assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); - assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); - assert_eq!(css_weight_style("Bold"), (700, "normal")); - assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); - assert_eq!(css_weight_style("Regular"), (400, "normal")); - assert_eq!(css_weight_style("Light"), (300, "normal")); - assert_eq!(css_weight_style("Thin Oblique"), (100, "italic")); - assert_eq!(css_weight_style("Black"), (900, "normal")); - - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); - if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { - basic.chars.font_family = Some("Montserrat".into()); - basic.chars.font_style = Some("Bold Italic".into()); - } - assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); - let opts = EpubOptions { - generic_families: known, - fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], - ..Default::default() - }; - let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); - let mut read = |path: &str| { - let mut s = String::new(); - z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); - s - }; - let css = read("OEBPS/style.css"); - assert!( - css.starts_with( - "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" - ), - "{css}" - ); - assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); - let opf = read("OEBPS/content.opf"); - assert!(opf.contains(""), "{opf}"); - assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); - // HTML takes the generic families too, and embeds nothing. - let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); - assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); - } - - #[test] - fn metadata_is_serialized_for_its_output_context() { - let mut d = Document::new(&NewDocument::default()); - d.title = "Field Notes & ".into(); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap(); - d.styles_mut() - .export_tags - .insert(format!("p:{}", story::BASIC_PARAGRAPH), designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into() }); - if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { - basic.chars.font_family = Some("Quoted \"Serif\" ".into()); - } - - let aid = AssetId(d.alloc()); - d.assets.insert( - aid, - Arc::new(Asset { - id: aid, - name: "Cover & Notes".into(), - mime: "image/png; profile=screen".into(), - data: Arc::new(vec![1]), - pixels: Some((1, 1)), - ..Default::default() - }), - ); - let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); - item.alt_text = "Cover \"A\" & B".into(); - item.content = Content::Graphic(Graphic { - asset: aid, - size: (1.0, 1.0), - xf: Affine::IDENTITY, - clip: None, - auto_fit: Default::default(), - fit_align: 4, - crop: [0.0; 4], - wrap: Default::default(), - fill: Default::default(), - }); - d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); - - let story = story_html(&d, sid); - assert!(story.contains("

    A&B <C> "D"

    ")); - let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); - assert!(html.contains("")); - assert!(html.contains("Field Notes & <Edition>")); - assert!(html.contains("alt=\"Cover "A" & B\"")); - assert!(html.contains("src=\"data:image/png;base64,AQ==\"")); - assert!(!html.contains("image/png; profile=screen")); - assert!(!stylesheet(&d).contains("")); - assert!(stylesheet(&d).contains("\\3c Alt\\3e ")); - - let bytes = export_epub( - &d, - &EpubOptions { - title: None, - author: Some("A & B".into()), - language: "English (US)".into(), - identifier: Some("notes&edition".into()), - cover: None, - ..Default::default() - }, - ) - .unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut opf = String::new(); - z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); - assert!(opf.contains("xml:lang=\"und\"")); - assert!(opf.contains("notes&edition")); - assert!(opf.contains("A & B")); - assert!(opf.contains("media-type=\"image/png\"")); - } - - /// "Body¹ text", a 2×2 table, then "Closing" with an endnote. - fn tables_and_notes() -> Document { - let mut d = Document::new(&NewDocument::default()); - let lid = d.default_layer(); - let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap(); - let st = d.story_mut(sid).unwrap(); - st.insert_note(4, "Foot note text", ParaFormat::default()); - let mut t = Table::new(1, 2, 2, 0, 0, 200.0); - for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { - t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); - } - let end = st.len(); - st.insert_table(end, t); - let end = st.len(); - st.insert(end, "Closing"); - let end = st.len(); - st.insert_endnote(end, "End note text", ParaFormat::default()); - d - } - - #[test] - fn tables_footnotes_and_endnotes_are_exported() { - let d = tables_and_notes(); - let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); - let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); - let mut html = String::new(); - z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); - let (t0, t1) = (html.find("").unwrap(), html.find("
    ").unwrap()); - for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { - let at = html.find(&format!("

    {cell}

    \n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); - assert!(t0 < at && at < t1); - } - assert_eq!(html.matches("").count(), 2); - - let noteref = "Body1 text"; - assert!(html.contains(noteref), "footnote reference:\n{html}"); - let aside = html.find("").unwrap(); - assert!(html[aside..aside_end].contains("1 Foot note text")); - assert!(aside < t0, "the footnote follows its paragraph"); - - assert!(html.contains("Closing1")); - let section = html.find("
    ").unwrap(); - assert!(section > html.find("Closing").unwrap()); - assert!(html[section..].contains("
  • ")); - assert!(html[section..].contains("1 End note text")); - let mut nav = String::new(); - z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); - assert!(nav.contains("href=\"content.xhtml#endnotes\"")); - - let page = export_html(&d, &HtmlOptions::default()); - assert!(page.contains("\n", self.epub_type("footnote")); + } + + /// The endnote whose reference is at byte `pos` of `st`: a note reference in `out`; the note + /// joins the endnotes section. + fn endnote(&mut self, st: &Story, pos: usize, depth: usize, out: &mut String) { + let Some(note) = st.endnotes.get(st.endnotes_before(pos)) else { return }; + let n = self.next_endnote; + self.next_endnote = n.saturating_add(1); + self.endnote_ids = self.endnote_ids.saturating_add(1); + let id = self.endnote_ids; + let label = xml_text(&self.doc.endnote_options.label(n)); + let _ = write!(out, "{label}", self.epub_type("noteref")); + let mut body = String::new(); + self.paragraphs(¬e.text, depth + 1, &format!("{label} "), &mut body); + let item = format!("\n{body}
  • \n", self.epub_type("endnote")); + self.endnotes.push((n, item)); + } + + /// The endnotes met so far, in number order, as one section (empty when there are none). + pub(crate) fn endnotes_section(&mut self) -> String { + if self.endnotes.is_empty() { + return String::new(); + } + self.endnotes.sort_by_key(|(n, _)| *n); + let doc = self.doc; + let o = &doc.endnote_options; + let mut out = format!("\n", self.epub_type("endnotes")); + if !o.heading.trim().is_empty() { + let (tag, class) = export_mapping(&doc.styles, &o.heading_style, false, &self.pc); + let _ = writeln!(out, "<{tag} class=\"{}\">{}", xml_attr(&class), xml_text(&o.heading)); + } + out.push_str("
      \n"); + for (_, item) in &self.endnotes { + out.push_str(item); + } + out.push_str("
    \n
    \n"); + out + } + + /// One table cell with its row and column spans, as `th` or `td`. + fn table_cell(&mut self, text: &Story, cell_tag: &str, (rs, cs): (usize, usize), depth: usize, out: &mut String) { + let _ = write!(out, "<{cell_tag}"); + if rs > 1 { + let _ = write!(out, " rowspan=\"{rs}\""); + } + if cs > 1 { + let _ = write!(out, " colspan=\"{cs}\""); + } + out.push('>'); + self.paragraphs(text, depth, "", out); + let _ = writeln!(out, ""); + } + + /// A table: header rows as `thead`, footer rows as `tfoot`, merged cells as row and column + /// spans (kept within their row group). + fn table(&mut self, t: &Table, depth: usize, out: &mut String) { + let (nr, nc) = (t.nrows(), t.ncols()); + if nr.checked_mul(nc) != Some(t.cells.len()) { + return; + } + let (h, f) = (t.header_rows(), t.footer_rows()); + let body_end = nr.saturating_sub(f).max(h); + let mut covered = vec![false; t.cells.len()]; + out.push_str("\n"); + for (rows, group, cell_tag) in [(0..h, "thead", "th"), (h..body_end, "tbody", "td"), (body_end..nr, "tfoot", "td")] { + if rows.is_empty() { + continue; + } + let _ = writeln!(out, "<{group}>"); + for r in rows.clone() { + out.push_str(""); + for c in 0..nc { + let i = r * nc + c; + if covered.get(i).copied().unwrap_or(true) { + continue; + } + let Some(cell) = t.cells.get(i) else { continue }; + let rs = (cell.row_span.max(1) as usize).min(rows.end - r); + let cs = (cell.col_span.max(1) as usize).min(nc - c); + for rr in r..r + rs { + for cc in c..c + cs { + if let Some(slot) = covered.get_mut(rr * nc + cc) { + *slot = true; + } + } + } + self.table_cell(&cell.text, cell_tag, (rs, cs), depth, out); + } + out.push_str("\n"); + } + let _ = writeln!(out, ""); + } + out.push_str("
    \n"); + } +} + +/// Run text with variables replaced by their values. +fn run_text(doc: &Document, raw: &str) -> String { + if raw.chars().any(|c| designcraft_doc::vars::var_index(c).is_some()) { + let sub: String = raw + .chars() + .map(|c| match designcraft_doc::vars::var_index(c) { + Some(i) => doc.variable_value(i, None).unwrap_or_default(), + None => c.to_string(), + }) + .collect(); + story_markup(&sub) + } else { + story_markup(raw) + } +} + +/// One story as XHTML: paragraphs, tables, and each footnote after the paragraph that refers to +/// it. Endnote references link to the endnotes section that the full export ends with. +pub fn story_html(doc: &Document, sid: StoryId) -> String { + StoryWriter::new(doc, true, None).story(sid, None) +} diff --git a/crates/epub/src/tests.rs b/crates/epub/src/tests.rs new file mode 100644 index 000000000..f216fe95f --- /dev/null +++ b/crates/epub/src/tests.rs @@ -0,0 +1,924 @@ +use super::*; +use designcraft_doc::build::NewDocument; +use designcraft_doc::geom::{Affine, Rect, shapes}; +use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, ParaFormat, Shape, SpreadRef, Story, StoryId, Table}; +use std::io::Read; +use std::sync::Arc; + +#[test] +fn exports_valid_package_in_reading_order() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Second & co", ParaFormat::default()).unwrap(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "First\nline two", ParaFormat::default()).unwrap(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + assert_eq!(z.by_index(0).unwrap().name(), "mimetype"); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let a = html.find("First").unwrap(); + let b = html.find("Second <b> & co").unwrap(); + assert!(a < b, "reading order"); + assert!(html.contains("

    ")); + let mut css = String::new(); + z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); + assert!(css.contains("p.basic-paragraph")); + assert!(z.by_name("OEBPS/nav.xhtml").is_ok()); +} + +/// #766: anchored graphics are exported where they are anchored (inline in the text, an +/// above-line object as a block of its own), and their images go into the package. +#[test] +fn anchored_objects_are_exported_in_place() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 200.0), lid, "AB\nCD", ParaFormat::default()).unwrap(); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: "dot.png".into(), + mime: "image/png".into(), + link: None, + data: Arc::new(vec![7]), + pixels: Some((1, 1)), + ..Default::default() + }), + ); + let graphic = |d: &mut Document, alt: &str| { + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + clip: None, + wrap: Default::default(), + fill: Default::default(), + }); + item + }; + let inline = designcraft_doc::AnchoredObject::new(graphic(&mut d, "inline"), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); + let above = designcraft_doc::AnchoredObject::new( + graphic(&mut d, "above"), + designcraft_doc::AnchorPosition::AboveLine { align: designcraft_doc::anchored::AnchorAlign::Center, space_before: 0.0, space_after: 0.0 }, + ); + let st = d.story_mut(sid).unwrap(); + st.insert_object(1, inline); + // Byte offsets: "A" (1), the mark (3), "B\nC" (3): the second object goes between C and D. + st.insert_object(7, above); + // An anchored image without alt text is decorative and reported, like a placed one. + let bare = designcraft_doc::AnchoredObject::new(graphic(&mut d, ""), designcraft_doc::AnchorPosition::Inline { y_offset: 0.0 }); + let end = d.story(sid).unwrap().text.len(); + d.story_mut(sid).unwrap().insert_object(end, bare); + assert_eq!(images_without_alt(&d), vec!["dot.png".to_string()]); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let img = |alt: &str| format!("\"{alt}\"/", aid.0); + assert!(html.contains(&format!("A{}B

    ", img("inline"))), "{html}"); + assert!(html.contains(&format!("C{}D", img("above"))), "{html}"); + assert!(z.by_name(&format!("OEBPS/images/dot-{}.png", aid.0)).is_ok(), "the image is in the package"); + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("alt=\"inline\"") && page.contains("alt=\"above\""), "{page}"); + // story_html on its own still leaves objects out. + assert!(!story_html(&d, sid).contains(" = z.file_names().map(str::to_string).collect(); + (s, names) + }; + let id_of = |opf: &str| opf.split("").nth(1).and_then(|r| r.split('<').next()).unwrap_or_default().to_string(); + let mut a = Document::new(&NewDocument::default()); + a.title = "Same".into(); + a.created = 1_700_000_000; + let mut b = a.clone(); + b.created = 1_700_000_001; + let (oa, _) = opf(&a, &EpubOptions::default()); + let (oa2, _) = opf(&a, &EpubOptions::default()); + let (ob, _) = opf(&b, &EpubOptions::default()); + assert!(id_of(&oa).starts_with("urn:uuid:") && id_of(&oa).len() == 45, "{}", id_of(&oa)); + assert_eq!(id_of(&oa), id_of(&oa2), "stable from export to export"); + assert_ne!(id_of(&oa), id_of(&ob), "not shared by two books with the same title"); + // Modified: the given time, else the document's save (or creation) time. + assert!(oa.contains("2023-11-14T22:13:20Z"), "{oa}"); + let at = EpubOptions { modified: Some(1_791_891_000), ..Default::default() }; + assert!(opf(&a, &at).0.contains("2026-10-13T11:30:00Z")); + // A JPEG cover is packaged as such. + let jpeg = EpubOptions { cover: Some(vec![0xFF, 0xD8, 0xFF, 0xE0, 1, 2]), ..Default::default() }; + let (o, names) = opf(&a, &jpeg); + assert!(o.contains("href=\"images/cover.jpg\" media-type=\"image/jpeg\" properties=\"cover-image\""), "{o}"); + assert!(names.contains(&"OEBPS/images/cover.jpg".to_string()), "{names:?}"); +} + +fn st_typed(d: &mut Document, sid: StoryId) { + if let Some(st) = d.story_mut(sid) { + st.paras[8].style = "Typed".into(); + } +} + +/// #767: consecutive `li` paragraphs share one list (`ol` for a numbered style), and +/// consecutive `pre` paragraphs of one style one block with their lines on separate lines. +#[test] +fn lists_and_preformatted_blocks_are_grouped() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + for (name, tag, list) in [ + ("Bullet", "li", Some(designcraft_doc::ListType::Bullets)), + ("Number", "li", Some(designcraft_doc::ListType::Numbers)), + ("Typed", "li", None), + ("Code", "pre", None), + ] { + let para = designcraft_doc::ParaAttrs { list_type: list, ..Default::default() }; + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para, + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); + } + let text = "Intro\nfirst\nsecond\none\ntwo\nx = 1\ny = 2\nEnd\n• typed"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in ["Bullet", "Bullet", "Number", "Number", "Code", "Code"].into_iter().enumerate() { + st.paras[i + 1].style = style.into(); + } + let html = story_html(&d, sid); + let li = |c: &str, t: &str| format!("
  • {t}
  • "); + assert!( + html.contains(&format!( + "
      \n{}\n{}\n
    \n
      \n{}\n{}\n
    \n", + li("bullet", "first"), + li("bullet", "second"), + li("number", "one"), + li("number", "two") + )), + "{html}" + ); + assert!(html.contains("
    x = 1\ny = 2
    \n

    End

    "), "{html}"); + assert_eq!(html.matches("\n
  • • typed
  • \n\n"), "{html}"); +} + +/// #769, #770: the navigation lists the h1–h3 headings (nested by level) and links to them, +/// with its title in the book's language; without headings, each story's first paragraph. +#[test] +fn navigation_follows_the_headings() { + let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() }); + let lid = d.default_layer(); + for (name, tag) in [("Chapter", "h1"), ("Section", "h2")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), class: String::new(), ..Default::default() }); + } + let text = "One\nBody\nOne point one\nMore\nTwo\nText"; + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 300.0), lid, text, ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(0, "Chapter"), (2, "Section"), (4, "Chapter")] { + st.paras[i].style = style.into(); + } + // A story that starts with body text adds nothing to the table of contents. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 400.0, 300.0, 500.0), lid, "Text after the figure.", ParaFormat::default()).unwrap(); + let read = |d: &Document, opts: &EpubOptions, path: &str| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let nl = EpubOptions { language: "nl".into(), ..Default::default() }; + let nav = read(&d, &nl, "OEBPS/nav.xhtml"); + let link = |f: &str, t: &str, l: &str| format!("{l}"); + // Split Document is off: every heading is in the one content file. + let want = format!( + "

    Inhoud

    1. {}
      1. {}
    2. {}
    ", + link("content.xhtml", "s0-p0", "One"), + link("content.xhtml", "s0-p2", "One point one"), + link("content.xhtml", "s0-p4", "Two") + ); + assert!(nav.contains(&want), "{nav}"); + assert!(!nav.contains("Text after the figure"), "{nav}"); + let content = read(&d, &nl, "OEBPS/content.xhtml"); + assert!(content.contains("

    One

    ") && content.contains("

    In dit boek

    ")); + // Without headings: one entry per story, its first paragraph. + let mut plain = Document::new(&NewDocument::default()); + let lid = plain.default_layer(); + plain.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Opening\nrest", ParaFormat::default()).unwrap(); + let nav = read(&plain, &EpubOptions::default(), "OEBPS/nav.xhtml"); + assert!(nav.contains(&format!("

    Contents

    1. {}
    ", link("content.xhtml", "s0", "Opening"))), "{nav}"); + // A heading shallower than the one before it but deeper than its parent stays under the + // parent: h1 A, h3 B, h2 C nest B and C under A. + let files: BTreeMap = "ABCD".chars().map(|c| (c.to_string(), c.to_string())).collect(); + let e = |t: &str, l: u8| (t.to_string(), t.to_string(), l); + assert_eq!( + nav_list(&[e("A", 1), e("B", 3), e("C", 2), e("D", 1)], &files), + format!( + "
  • {}
    1. {}
    2. {}
  • {}
  • ", + link("A", "A", "A"), + link("B", "B", "B"), + link("C", "C", "C"), + link("D", "D", "D") + ) + ); + assert_eq!( + nav_list(&[e("A", 1), e("B", 2), e("C", 3), e("D", 1)], &files), + format!( + "
  • {}
    1. {}
      1. {}
  • {}
  • ", + link("A", "A", "A"), + link("B", "B", "B"), + link("C", "C", "C"), + link("D", "D", "D") + ) + ); +} + +/// Split Document off (the default) gives one content file, h1s and all. Single Paragraph +/// Style starts a file before each paragraph of that style, with well-formed sections; the +/// spine and manifest list the files in order. +#[test] +fn chapters_split_before_a_single_paragraph_style() { + let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() }); + let lid = d.default_layer(); + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "Chapter".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); + // A title page without a heading, then a story that opens with a chapter and has a + // second one in the middle. + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Title page", ParaFormat::default()).unwrap(); + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 200.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].style = "Chapter".into(); + st.paras[2].style = "Chapter".into(); + let one_file = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut c = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut c).unwrap(); + z.by_name("OEBPS/content2.xhtml").is_err() && c.contains("Title page") && c.contains("Second text") && !c.contains(CHAPTER_MARK) + }; + assert!(one_file(&d, &EpubOptions::default()), "off by default"); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); + assert!(one_file(&d, &EpubOptions { split: Some(EpubSplit::Off), ..Default::default() }), "the export's own setting wins"); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (c1, c2, c3) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml"), read("OEBPS/content3.xhtml")); + assert!(c1.contains("Title page") && !c1.contains("One"), "{c1}"); + assert!(c2.contains("
    \n

    \n

    ").count(), "{c}"); + assert!(!c.contains(CHAPTER_MARK)); + } + let opf = read("OEBPS/content.opf"); + assert!(opf.contains("\n\n\n"), "{opf}"); + assert!(opf.contains("").count(), "{c}"); + } + let nav = read("OEBPS/nav.xhtml"); + assert!(nav.contains("href=\"content2.xhtml#s1-p1\"") && nav.contains("href=\"content3.xhtml#s1-p4\""), "{nav}"); + assert!(c2.contains("href=\"content3.xhtml#en-1\""), "{c2}"); + assert!(c3.contains("href=\"content2.xhtml#enref-1\""), "the endnote links back across files: {c3}"); + assert!(z.by_name("OEBPS/content4.xhtml").is_err()); +} + +/// Note links stay working across chapter files: endnotes end the book, in the last file. +#[test] +fn note_links_point_at_the_chapter_file_of_their_target() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: "Chapter".into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut() + .export_tags + .insert("p:Chapter".into(), designcraft_doc::ExportTag { tag: "h1".into(), class: String::new(), ..Default::default() }); + let (_, sid) = d + .add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "One\nFirst text\nTwo\nSecond text", ParaFormat::default()) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + st.paras[0].style = "Chapter".into(); + st.paras[2].style = "Chapter".into(); + // After "First": an endnote and a footnote. + st.insert_endnote(9, "End note", ParaFormat::default()); + st.insert_note(9, "Foot note", ParaFormat::default()); + d.settings.epub_split = EpubSplit::Style("Chapter".into()); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let (first, last) = (read("OEBPS/content.xhtml"), read("OEBPS/content2.xhtml")); + assert!(first.contains("id=\"enref-1\" href=\"content2.xhtml#en-1\""), "{first}"); + assert!(first.contains("href=\"#fn-1\"") && first.contains("href=\"#fnref-1\""), "a footnote stays in its chapter: {first}"); + assert!(last.contains("Second text") && last.contains(""), "{last}"); +} + +/// A split before a list item or a preformatted line closes the open list or block first and +/// reopens it in the new file, so every file is balanced. +#[test] +fn chapter_files_split_inside_lists_and_pre_stay_balanced() { + let mut d = Document::new(&NewDocument::default()); + d.settings.epub_split = EpubSplit::ExportTags; + let lid = d.default_layer(); + for (name, tag) in [("Item", "li"), ("Code", "pre")] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs::default(), + shortcut: String::new(), + }); + d.styles_mut().export_tags.insert(format!("p:{name}"), designcraft_doc::ExportTag { tag: tag.into(), split: true, ..Default::default() }); + } + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(36.0, 36.0, 300.0, 300.0), + lid, + "Intro +one +two +x = 1 +y = 2 +End", + ParaFormat::default(), + ) + .unwrap(); + let st = d.story_mut(sid).unwrap(); + for (i, style) in [(1, "Item"), (2, "Item"), (3, "Code"), (4, "Code")] { + st.paras[i].style = style.into(); + } + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let files: Vec = ["content", "content2", "content3", "content4", "content5"].iter().map(|f| read(&format!("OEBPS/{f}.xhtml"))).collect(); + for (c, text) in files.iter().zip(["Intro", "one", "two", "x = 1", "y = 2"]) { + assert!(c.contains(text), "{text}: {c}"); + for (open, close) in [(""), (""), ("")] { + assert_eq!(c.matches(open).count(), c.matches(close).count(), "{open}: {c}"); + } + } + assert!(files[1].contains("\n

    a

    \n

    \n"), vec!["
    \n

    a

    \n
    \n"]); + assert_eq!(split_chapters(&format!("
    \n{m}

    A

    \n
    \n")), vec!["
    \n

    A

    \n
    \n"]); + assert_eq!( + split_chapters(&format!("
    \n

    a

    \n{m}

    B

    \n
    \n")), + vec!["
    \n

    a

    \n
    \n", "
    \n

    B

    \n
    \n"] + ); + assert_eq!(split_chapters(""), vec![String::new()]); +} + +#[test] +fn styles_whose_names_slug_alike_keep_their_own_classes() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + for (name, size) in [("見出し", 30.0), ("本文", 10.0), ("Body Text", 12.0), ("Body-Text", 20.0)] { + d.styles_mut().paragraph.push(designcraft_doc::ParagraphStyle { + name: name.into(), + based_on: None, + next_style: None, + para: Default::default(), + chars: CharAttrs { size: Some(size), ..Default::default() }, + shortcut: String::new(), + }); + } + for (k, name) in ["見出し", "本文", "Body Text", "Body-Text"].into_iter().enumerate() { + let y = 36.0 + 100.0 * k as f64; + let pf = ParaFormat { style: name.into(), ..Default::default() }; + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, y, 300.0, y + 50.0), lid, &format!("P{k}"), pf).unwrap(); + } + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let mut css = String::new(); + z.by_name("OEBPS/style.css").unwrap().read_to_string(&mut css).unwrap(); + let class_of = |text: &str| { + let end = html.find(&format!("\">{text}

    ")).unwrap(); + let start = html[..end].rfind("class=\"").unwrap() + 7; + html[start..end].to_string() + }; + let classes: Vec = (0..4).map(|k| class_of(&format!("P{k}"))).collect(); + for (k, c) in classes.iter().enumerate() { + assert!(!classes[..k].contains(c), "class `{c}` shared: {classes:?}"); + assert_eq!(css.matches(&format!("p.{c} {{")).count(), 1, "one rule for `{c}`:\n{css}"); + } +} + +#[test] +fn slugs() { + assert_eq!(slug("[Basic Paragraph]"), "basic-paragraph"); + assert_eq!(slug("Body First"), "body-first"); + assert_eq!(slug("1 Head"), "s-1-head"); +} + +#[test] +fn html_is_one_self_contained_page() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello ", ParaFormat::default()).unwrap(); + let html = export_html(&d, &HtmlOptions { title: Some("T".into()), language: None, ..Default::default() }); + assert!(html.starts_with("") && html.contains("T") && html.contains("Hello <web>")); + assert!(html.contains("p.basic-paragraph"), "styles inline"); + assert_eq!(base64(b"Man"), "TWFu"); + assert_eq!(base64(b"Ma"), "TWE="); + assert_eq!(base64(b"M"), "TQ=="); +} + +/// #774: an image without alt text is decorative (`alt=""`) and reported, and package names +/// come from the original file names. +#[test] +fn images_get_readable_names_and_no_file_name_alt() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let place = |d: &mut Document, name: &str, mime: &str, alt: &str, y: f64| { + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + page: 0, + id: aid, + name: name.into(), + mime: mime.into(), + link: None, + data: Arc::new(vec![1]), + pixels: Some((1, 1)), + ..Default::default() + }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0))); + item.alt_text = alt.into(); + item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + clip: None, + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + aid.0 + }; + let a = place(&mut d, "/tmp/Anim Frame (1).GIF", "image/gif", "", 36.0); + let b = place(&mut d, "photo.jpg", "image/jpeg", "A heron", 120.0); + let c = place(&mut d, "???.png", "image/png", "Mark", 200.0); + assert_eq!(images_without_alt(&d), vec!["/tmp/Anim Frame (1).GIF".to_string()]); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &EpubOptions::default()).unwrap())).unwrap(); + let mut x = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut x).unwrap(); + assert!(x.contains(&format!("\"\"/")), "{x}"); + assert!(x.contains(&format!("\"A")), "{x}"); + assert!(x.contains(&format!("\"Mark\"/")), "{x}"); + assert!(z.by_name(&format!("OEBPS/images/anim-frame-1-{a}.gif")).is_ok()); +} + +/// #775: the generic fallback follows the font, and embedded fonts are packaged and declared. +#[test] +fn generic_fallbacks_and_embedded_fonts() { + let none = BTreeMap::new(); + for (family, want) in [ + ("Source Serif 4", "serif"), + ("Source Sans 3", "sans-serif"), + ("Microsoft Sans Serif", "sans-serif"), + ("JetBrains Mono", "monospace"), + ("Courier New", "monospace"), + ("Franklin Gothic", "sans-serif"), + ("Garamond", "serif"), + ] { + assert_eq!(generic_family(family, &none), want, "{family}"); + } + let known: BTreeMap = [("Montserrat".to_string(), "sans-serif".to_string()), ("Odd".to_string(), "nonsense".to_string())].into(); + assert_eq!(generic_family("Montserrat", &known), "sans-serif"); + assert_eq!(generic_family("Odd", &known), "serif", "only CSS generic families are used"); + assert_eq!(css_weight_style("Semibold Italic"), (600, "italic")); + assert_eq!(css_weight_style("Bold"), (700, "normal")); + assert_eq!(css_weight_style("Extra Bold"), (800, "normal")); + assert_eq!(css_weight_style("Regular"), (400, "normal")); + assert_eq!(css_weight_style("Light"), (300, "normal")); + assert_eq!(css_weight_style("Thin Oblique"), (100, "italic")); + assert_eq!(css_weight_style("Black"), (900, "normal")); + + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "Hello", ParaFormat::default()).unwrap(); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Montserrat".into()); + basic.chars.font_style = Some("Bold Italic".into()); + } + assert_eq!(stylesheet_fonts(&d), vec![("Montserrat".to_string(), "Bold Italic".to_string())]); + let opts = EpubOptions { + generic_families: known, + fonts: vec![EmbeddedFont { family: "Montserrat".into(), style: "Bold Italic".into(), data: b"OTTO font".to_vec() }], + ..Default::default() + }; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(&d, &opts).unwrap())).unwrap(); + let mut read = |path: &str| { + let mut s = String::new(); + z.by_name(path).unwrap().read_to_string(&mut s).unwrap(); + s + }; + let css = read("OEBPS/style.css"); + assert!( + css.starts_with( + "@font-face { font-family: \"Montserrat\"; src: url(\"fonts/0-montserrat-bold-italic.otf\"); font-weight: 700; font-style: italic; }" + ), + "{css}" + ); + assert!(css.contains("font-family: \"Montserrat\", sans-serif;"), "{css}"); + let opf = read("OEBPS/content.opf"); + assert!(opf.contains(""), "{opf}"); + assert_eq!(read("OEBPS/fonts/0-montserrat-bold-italic.otf"), "OTTO font"); + // HTML takes the generic families too, and embeds nothing. + let html = export_html(&d, &HtmlOptions { generic_families: opts.generic_families.clone(), ..Default::default() }); + assert!(html.contains("\"Montserrat\", sans-serif;") && !html.contains("@font-face"), "{html}"); +} + +#[test] +fn metadata_is_serialized_for_its_output_context() { + let mut d = Document::new(&NewDocument::default()); + d.title = "Field Notes & ".into(); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 100.0), lid, "A&B \"D\"", ParaFormat::default()).unwrap(); + d.styles_mut().export_tags.insert( + format!("p:{}", story::BASIC_PARAGRAPH), + designcraft_doc::ExportTag { tag: "h2".into(), class: "Primary \"Copy\"".into(), ..Default::default() }, + ); + if let Some(basic) = d.styles_mut().paragraph.iter_mut().find(|s| s.name == story::BASIC_PARAGRAPH) { + basic.chars.font_family = Some("Quoted \"Serif\" ".into()); + } + + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { + id: aid, + name: "Cover & Notes".into(), + mime: "image/png; profile=screen".into(), + data: Arc::new(vec![1]), + pixels: Some((1, 1)), + ..Default::default() + }), + ); + let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0))); + item.alt_text = "Cover \"A\" & B".into(); + item.content = Content::Graphic(Graphic { + layer_options: None, + asset: aid, + size: (1.0, 1.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + d.insert_item(SpreadRef::Doc(0), item, None).unwrap(); + + let story = story_html(&d, sid); + assert!(story.contains("

    A&B <C> "D"

    ")); + let html = export_html(&d, &HtmlOptions { title: None, language: Some("English (US)".into()), ..Default::default() }); + assert!(html.contains("")); + assert!(html.contains("Field Notes & <Edition>")); + assert!(html.contains("alt=\"Cover "A" & B\"")); + assert!(html.contains("src=\"data:image/png;base64,AQ==\"")); + assert!(!html.contains("image/png; profile=screen")); + assert!(!stylesheet(&d).contains("")); + assert!(stylesheet(&d).contains("\\3c Alt\\3e ")); + + let bytes = export_epub( + &d, + &EpubOptions { + title: None, + author: Some("A & B".into()), + language: "English (US)".into(), + identifier: Some("notes&edition".into()), + cover: None, + ..Default::default() + }, + ) + .unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + assert!(opf.contains("xml:lang=\"und\"")); + assert!(opf.contains("notes&edition")); + assert!(opf.contains("A & B")); + assert!(opf.contains("media-type=\"image/png\"")); +} + +/// File › File Info's Document Title and Author name the EPUB and the HTML page; the export +/// options' title and author replace them. +#[test] +fn file_info_titles_the_epub_and_the_html_page() { + let mut d = Document::new(&NewDocument { title: "flyer-v3".into(), ..Default::default() }); + d.file_info.document_title = "Spring & Summer".into(); + d.file_info.author = "Ana Ruiz".into(); + let opf = |d: &Document, opts: &EpubOptions| { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(export_epub(d, opts).unwrap())).unwrap(); + let mut opf = String::new(); + z.by_name("OEBPS/content.opf").unwrap().read_to_string(&mut opf).unwrap(); + opf + }; + let o = opf(&d, &EpubOptions::default()); + assert!(o.contains("Spring & Summer") && o.contains("Ana Ruiz"), "{o}"); + let o = opf(&d, &EpubOptions { title: Some("Proof".into()), author: Some("Studio".into()), ..Default::default() }); + assert!(o.contains("Proof") && o.contains("Studio"), "{o}"); + let html = export_html(&d, &HtmlOptions::default()); + assert!(html.contains("Spring & Summer")); + d.file_info = Default::default(); + let o = opf(&d, &EpubOptions::default()); + assert!(o.contains("flyer-v3") && !o.contains(""), "{o}"); +} + +/// "Body¹ text", a 2×2 table, then "Closing" with an endnote. +fn tables_and_notes() -> Document { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), lid, "Body text", ParaFormat::default()).unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert_note(4, "Foot note text", ParaFormat::default()); + let mut t = Table::new(1, 2, 2, 0, 0, 200.0); + for (r, c, s) in [(0, 0, "Cell A1"), (0, 1, "Cell B1"), (1, 0, "Cell A2"), (1, 1, "Cell B2")] { + t.cell_mut(r, c).unwrap().text = Story::with_text(StoryId(0), s, ParaFormat::default()); + } + let end = st.len(); + st.insert_table(end, t); + let end = st.len(); + st.insert(end, "Closing"); + let end = st.len(); + st.insert_endnote(end, "End note text", ParaFormat::default()); + d +} + +#[test] +fn tables_footnotes_and_endnotes_are_exported() { + let d = tables_and_notes(); + let bytes = export_epub(&d, &EpubOptions::default()).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut html = String::new(); + z.by_name("OEBPS/content.xhtml").unwrap().read_to_string(&mut html).unwrap(); + let (t0, t1) = (html.find("").unwrap(), html.find("
    ").unwrap()); + for cell in ["Cell A1", "Cell B1", "Cell A2", "Cell B2"] { + let at = html.find(&format!("

    {cell}

    \n")).unwrap_or_else(|| panic!("{cell} in a cell:\n{html}")); + assert!(t0 < at && at < t1); + } + assert_eq!(html.matches("").count(), 2); + + let noteref = "Body
    1 text"; + assert!(html.contains(noteref), "footnote reference:\n{html}"); + let aside = html.find("").unwrap(); + assert!(html[aside..aside_end].contains("1 Foot note text")); + assert!(aside < t0, "the footnote follows its paragraph"); + + assert!(html.contains("Closing1")); + let section = html.find("
    ").unwrap(); + assert!(section > html.find("Closing").unwrap()); + assert!(html[section..].contains("
  • ")); + assert!(html[section..].contains("1 End note text")); + let mut nav = String::new(); + z.by_name("OEBPS/nav.xhtml").unwrap().read_to_string(&mut nav).unwrap(); + assert!(nav.contains("href=\"content.xhtml#endnotes\"")); + + let page = export_html(&d, &HtmlOptions::default()); + assert!(page.contains("