diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d35b663209..c9acb0512a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (271) +## In this repository (272) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -279,6 +279,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format | | `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 5734c03954..018360c26b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0ef505ad4ae53d404d4474d2881f54bc3692739fe9200692fad2b3ace9036a03" +sha256 = "393902c1a2e21346b5a328a1a8792bfc361cb0872a465901209e36d78ca45b79" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "73efe98c3a4190715f3f76d714390396887f45d2092926729832b79a194488c0" +sha256 = "d90200f734b322b9987cae89a1e6e7d98c10fd7071047d9d6538d3e58e0312db" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e0959af2ee8e317372d9076e487e41d025015460b99906482efa1a0e528802c3" +sha256 = "3b24582f96c26d91afb592a787c20115e5adeacf5672cf2d3fe265ec25e012bf" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0a327af10b0bb916ef1b912e6797214bd0b2c18c1e0ef27d9f7a92a3cbeac5b7" +sha256 = "d3cd4e5358351adb254f4969a3c2d579c43a9b1c140ee2501a28bf09bd63b677" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c2cf9490a9f9c828382e2f24df3eaea511b4dfab6543ca00ac61e0014029f051" +sha256 = "74c82a97934c5423d1fe6c56a4cc23ed821bb1abacfff258c2a64414aa13fb29" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fccce321ccb64bdfcb75e1d6d09f661fb6a738b30cc0ec1133134ea91959e3ad" +sha256 = "46e7ca20f0a6851c77fa73f3d9401ea9e9141f592d03be906328342924907310" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "64722973eaf542884ddd170dd9541649c1b1b365ad8c63d0d2864ffbecd20941" +sha256 = "5f51fd699f88c01828ce6386ac92814a16c32a1815ad3b2fb745e231376a6db2" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0475c03f0b2190998e247d6996d5473d3fd85ea6c55e582b7159b42096c63f9e" +sha256 = "3a840c78fcf5ea19d0e683c78874ebc4ed46025595c84eb12cea4713f7d986d3" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6423cc0904fd4e49af25121d9fe4d67d09d684f45acbb8165d4ea7a474a3275c" +sha256 = "9e7866f38b811211b5b145052c11018c26d0e0d6b3e5edad93a9c2d7fba008a7" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "588adcef83d5ec8e9a1e1fac093afa6afee30d94ac68e8b428ebd487cdc23c3e" +sha256 = "0d46bc90f3fd0faef2542b5958ddb931cfae56bca5b9ce6e69e805dfee50505a" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9fda2deb8db4e919199ae032b9a9a220f32896462640578cd155074809e71f0d" +sha256 = "bfa4d801835202166874f717d9bda7c14731570bdc63b3e4c58f5dc93be02998" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b60244d234bcec0602a10a077167861d60c86ab3a364c3dfc4b4e0466dd54872" +sha256 = "24092581310895dca1f6ed2d88f582749cc776aedb13ae7d5d4dafd67efc1bad" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "18416c5dc7b28bfb999e1f8127337d37915c67a91d8b8b3fc52e3286a11205ba" +sha256 = "6fb3b98133d9af06d27dce35a1c0f370c6d16702b82598ffaa2909b05e9e9be4" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e50991d9d81a9a45e78c719c790029486e4c03045c30b10b4fee349fcc6702d0" +sha256 = "784f6dd4534dc842fd18e0c0af790af92e5caf32732d0ef85f88a094f0591a48" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "73fb4046cd4fdccd8c1da4abfab90ecd0ca2395fc9a6302dd00dd4f810a14418" +sha256 = "2fd1b542c391537423a5b36a0617886fc4ca8abc8aa595cc4bd700eb5600760f" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e090ca970a518bbf667959181167ff8f8058b78e4239c0c62654c58450738a99" +sha256 = "9c3ae4e8692e9715f6a1cfb5eb0e7ef02d03d222833ca9cf11e286f222e8f024" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a5e4cbf2fd2b1f840d31482a090a68fe58dbbd03e2187deecd1398d091c9fb8f" +sha256 = "6a44916924fd9fed55e97e9e9aed10299bd1c2cdd3213530afc328fcc3382b8a" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "036dfc2952a758352933ec9ab7f1668a699ed108c8902340566f149df4bc0b79" +sha256 = "623bf25e7f0dc783e497d2744ac7fa17437ef3cc9c65a25b91c82321700ae037" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d4ac259fc9dad897c9ca8a55eb830d2027f3387eafd641744dd7b9d43a5d1cb4" +sha256 = "73b8af9831358bc6ea9124fd7a05b725cf80c89709348ac07680646bf11378d7" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b0e3dacf8ce437663e3cb9946ced5b9f3cde6a62a2bf5ff8dd3d45268b264bf5" +sha256 = "b9edcc284143606785999408fb3c2e6ed61bd9d973225b6a37fdbbfbd226093f" [[asset]] @@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f9b0694f684bea95e20bd33bef33fedfd92e28c62f2c140193c89e0ed7a18b97" +sha256 = "8f4d695f2a20a0a8470181c343b2a832d7cd05a7645aa07fe5e4a94299a84b82" [[asset]] path = "assets/icons/accessibility.svg" @@ -3363,4 +3363,15 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "eb1f9d2990ab5e177be78d5d5e6d4c3cbdb0961c64df04bbd34e5d9ad5b42a3d" +sha256 = "d5b66bf35341a9ce1d66204a99cfc5679c66c26f7afb0bb394d87335783d229d" + +[[asset]] +path = "vendor/vello_common/assets/probe.png" +title = "vello_common probe reference image (rendered from probe.rgba)" +author = "Linebender vello contributors" +source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary" +licence = "MIT OR Apache-2.0" +licence_file = "vendor/vello_common/LICENSE-MIT" +kind = "image" +usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file" +sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" diff --git a/Cargo.lock b/Cargo.lock index d7dc06ab5a..678a07ffa6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3884,6 +3884,7 @@ dependencies = [ "pdfcraft-engine", "pdfcraft-ui-egui", "pollster 1.0.1", + "raw-window-handle", "rustls-native-certs", "serde_json", "uds_windows", @@ -3959,6 +3960,7 @@ dependencies = [ "pdfcraft-automation", "pdfcraft-cos", "pdfcraft-engine", + "pdfcraft-platform", "pdfcraft-render", "rustix 1.1.5", "serde_json", @@ -4185,6 +4187,7 @@ version = "0.8.0" dependencies = [ "async-channel", "futures-lite", + "rustix 1.1.5", "zbus", ] @@ -4192,6 +4195,7 @@ dependencies = [ name = "pdfcraft-preflight" version = "0.8.0" dependencies = [ + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", ] @@ -4219,6 +4223,7 @@ dependencies = [ "pdfcraft-forms", "pdfcraft-model", "pdfcraft-testkit", + "pdfcraft-xfa", "thiserror 2.0.21", ] @@ -6882,8 +6887,6 @@ dependencies = [ [[package]] name = "vello_common" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ "bytemuck", "fearless_simd", diff --git a/Cargo.toml b/Cargo.toml index 84b2ed1f20..41c9f90e86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,3 +112,4 @@ hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } winit = { path = "vendor/winit" } egui-wgpu = { path = "vendor/egui-wgpu" } +vello_common = { path = "vendor/vello_common" } diff --git a/README.md b/README.md index 86f7d7643a..bd33ad23be 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Every page of a combined document renders pixel-identical to its source. ```sh pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf -pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf +pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf pdfcraft-cli split report.pdf --every 10 --out-dir parts/ ``` @@ -285,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"} pdfcraft-cli run --script steps.json --root ./work # several steps in one session + pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error ``` With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it). @@ -452,17 +453,40 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no `%APPDATA%`; delete that file to use the normal per-user folders. The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft" -to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"), -without changing which app opens PDFs by default. For unattended deployment without a desktop -shortcut, run from an elevated terminal: +to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of +images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under +"Show more options"), without changing which app opens them by default. For unattended deployment +without a desktop shortcut, run from an elevated terminal: ```powershell msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0 ``` -Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too, -since each install applies its own choices. Use the MSI for your architecture. Per-user -installation overrides are not supported. +| Property | `=0` leaves out | +|---|---| +| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut | +| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | +| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | + +All three are on for a first install. An upgrade keeps what the installed version has: a shortcut +or menu entry that is there stays, and one that is missing (left out at install, or a desktop +shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the +command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that +didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu) +adds it, as a first install would. Use the MSI for your architecture. Per-user installation +overrides are not supported. + +**Pin the checksum, not just the version.** Release candidates of a version share its MSI version +(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an +MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't +be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from +`SHA256SUMS.txt` (`Get-FileHash -Algorithm SHA256`). + +**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted +Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app +and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and +notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is +`Learning Machines LLC`. ### macOS diff --git a/ROADMAP.md b/ROADMAP.md index ed84b6ab4e..98936d0999 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,13 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -153,13 +158,39 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. +- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. +- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. +- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given. +- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged. + - **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged. - **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged. - **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged. - **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix. +- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged. +- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged. +- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged. +- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged. +- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged. +- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged. +- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged. + +- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned. +- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned. +- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions. +- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening. +- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged. +- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged. +- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. +- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. +- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. +- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged. + - **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged. +- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged. - **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged. - **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged. - **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged. @@ -439,6 +470,8 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | +| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. | | 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. | | 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged | | 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. | diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index abe2657fbc..d7b52fbd66 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -19,6 +19,7 @@ pdfcraft-engine = { workspace = true } pdfcraft-audit = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-automation = { workspace = true } +pdfcraft-platform = { workspace = true } base64 = "0.22" [target.'cfg(unix)'.dependencies] diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 7f053e055f..e749eb54d9 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -55,7 +55,7 @@ pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--m [--insert-blank 1] [--title T] [--author A] [--full] (options run in order; page numbers refer to the document after the earlier options) pdfcraft-cli combine … --out combined.pdf -pdfcraft-cli extract --pages 1,3,5 --out out.pdf +pdfcraft-cli extract --pages 1,3,5-8 --out out.pdf (pages and ranges, in the order given) pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] pdfcraft-cli audit [--password PW] find faux redactions (fails when found) @@ -64,7 +64,8 @@ pdfcraft-cli batch --steps convert,ocr,optimize --out-dir DIR [--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages] pdfcraft-cli tools automation tools and their JSON Schemas pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] -pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …] +pdfcraft-cli run --script steps.json [--root DIR] [--report] + [{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run ", mcp_usage!(), "\ @@ -256,6 +257,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[ OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--script", takes_value: true }, OptionSpec { name: "--out", takes_value: true }, + OptionSpec { name: "--report", takes_value: false }, ]; const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }]; @@ -310,6 +312,7 @@ fn info(args: &[String]) -> Result<(), CliError> { "xfa_layout": xfa_layout, "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), + "transitions": info.transitions.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); @@ -381,9 +384,30 @@ fn text(args: &[String]) -> Result<(), CliError> { } } -/// 1-based page list ("1,3,5") → 0-based indices. +/// The most pages one `A-B` range may name: a bound on what a typed range can allocate. +const MAX_RANGE: usize = 1_000_000; + +/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based +/// indices (#1923). fn page_list(s: &str) -> Result, String> { - s.split(',').map(|p| p.trim().parse::().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect() + let number = |t: &str, item: &str| t.trim().parse::().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}")); + let mut out = Vec::new(); + for item in s.split(',').map(str::trim) { + match item.split_once('-') { + Some((from, to)) => { + let (from, to) = (number(from, item)?, number(to, item)?); + if to < from { + return Err(format!("bad page range {item:?}: it ends before it starts")); + } + if to - from >= MAX_RANGE { + return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages")); + } + out.extend(from - 1..to); + } + None => out.push(number(item, item)? - 1), + } + } + Ok(out) } /// The options `edit` understands, for its "unknown option" message. @@ -474,7 +498,16 @@ fn edit(args: &[String]) -> Result<(), CliError> { session.apply(id, e).map_err(|e| e.to_string())?; } let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?; - std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) + // As the app's Save: the file must reopen with its pages before it replaces anything (#1698). + session.check_save(id, &bytes).map_err(|e| e.to_string())?; + write_pdf(Path::new(out), &bytes) +} + +/// Write a PDF a command produced, through a staging file beside the target (#2166): a write +/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was. +fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> { + use pdfcraft_platform::staging::{StagingName, write_atomically}; + write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into()) } fn file_stem(path: &str) -> String { @@ -490,7 +523,7 @@ fn combine(args: &[String]) -> Result<(), CliError> { } let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::, String>>()?; let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?; - std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) + write_pdf(Path::new(out), &bytes) } fn extract(args: &[String]) -> Result<(), CliError> { @@ -501,7 +534,7 @@ fn extract(args: &[String]) -> Result<(), CliError> { let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?; let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?; - std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into()) + write_pdf(Path::new(out), &bytes) } fn split(args: &[String]) -> Result<(), CliError> { @@ -523,7 +556,7 @@ fn split(args: &[String]) -> Result<(), CliError> { std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?; for (a, b, bytes) in parts { let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") }); - std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?; + write_pdf(&name, &bytes)?; stdout_line(format_args!("{}", name.display()))?; } Ok(()) @@ -544,8 +577,11 @@ fn render(args: &[String]) -> Result<(), CliError> { Some("pam") => None, _ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()), }; + // The app renders a display copy that draws comments missing their /AP streams; the CLI + // must agree with it (and with independent viewers), while keeping the file it reads intact. + let bytes = read(path)?; let mut r = PageRenderer::new( - read(path)?, + pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, ); let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false }); @@ -762,22 +798,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec stdout_line(format_args!( - "{}", - serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) - ))?, + None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?, }, } } Ok(()) } +/// What a PNG result is reported as when it isn't written to a file. +fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value { + serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" }) +} + +/// A tool's result as JSON values, for the `--report` of a script. Same content as +/// [`print_output`], collected instead of printed. +fn output_values( + auto: &pdfcraft_automation::Automation, + content: Vec, + out: Option<&str>, +) -> Result, String> { + let mut values = Vec::new(); + for c in content { + values.push(match c { + pdfcraft_automation::Content::Json(v) => v, + pdfcraft_automation::Content::Png { data, width, height } => match out { + Some(path) => { + let written = auto.write_output(path, &data).map_err(|e| e.to_string())?; + serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height }) + } + None => png_summary(&data, width, height), + }, + }); + } + Ok(values) +} + +/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped` +/// with the result or the message. The first failing step ends the run, as without `--report`; +/// the steps after it are listed as skipped and the exit status is the same. +fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> { + let mut report = Vec::with_capacity(steps.len()); + let mut failure: Option = None; + for (i, step) in steps.iter().enumerate() { + let n = i + 1; + let tool = step["tool"].as_str(); + if failure.is_some() { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" })); + continue; + } + let outcome = match tool { + None => Err(format!("step {n}: missing \"tool\"")), + Some(t) => auto + .call(t, &step["args"]) + .map_err(|e| e.to_string()) + .and_then(|content| output_values(auto, content, step["out"].as_str())) + .map_err(|e| format!("step {n} ({t}): {e}")), + }; + match outcome { + Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })), + Err(message) => { + report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message })); + failure = Some(message); + } + } + } + let document = serde_json::json!({ "ok": failure.is_none(), "steps": report }); + stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?; + failure.map_or(Ok(()), |message| Err(CliError::Message(message))) +} + fn run(args: &[String]) -> Result<(), CliError> { validate_options("run", args, RUN_OPTIONS)?; + let report = args.iter().any(|a| a == "--report"); + if report && flag(args, "--script").is_none() { + return Err("run: --report needs --script".to_string().into()); + } let mut auto = automation(args)?; if let Some(script) = flag(args, "--script") { let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?; let steps: Vec = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?; + if report { + return run_script_report(&mut auto, &steps); + } for (i, step) in steps.iter().enumerate() { let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?; let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?; diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index c9a5e7e2b3..2864dcfb3c 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -92,6 +92,20 @@ fn the_documented_options_still_edit_and_save() { } } +/// `--out` naming the input replaces it through a staging file (#2166): the result is the +/// edited, reopenable file, and nothing is left beside it. +#[test] +fn editing_in_place_leaves_a_valid_file_and_no_staging_file() { + let input = tmp("in-place", "doc.pdf"); + std::fs::write(&input, fixture()).unwrap(); + let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", input.to_str().unwrap(), "--rotate", "1:90"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(first_page_rotation(&input), 90); + let names: Vec = + std::fs::read_dir(input.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["doc.pdf"], "no staging file beside the result"); +} + mod one_based_arguments { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/extract.rs new file mode 100644 index 0000000000..25f1aabc29 --- /dev/null +++ b/apps/pdfcraft-cli/tests/extract.rs @@ -0,0 +1,129 @@ +//! `extract --pages`: single pages and ranges (#1923), and an out-of-range page named as the +//! user typed it (1-based; it was off by one in 0.7.1). + +use std::path::PathBuf; +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A PDF with `n` empty 200×300 pt pages. +fn fixture(n: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); + for _ in 0..n { + objs.push(b"<< /Type /Page /Parent 2 0 R >>".to_vec()); + } + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +", + i + 1 + ) + .as_bytes(), + ); + out.extend_from_slice(o); + out.extend_from_slice( + b" +endobj +", + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{xref} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + out +} + +/// A fresh directory holding `five.pdf` (5 pages). +fn workdir(test: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-cli-extract-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("five.pdf"), fixture(5)).unwrap(); + d +} + +fn extract(dir: &std::path::Path, pages: &str) -> Output { + let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_"))); + Command::new(BIN).args(["extract", dir.join("five.pdf").to_str().unwrap(), "--pages", pages, "--out", out.to_str().unwrap()]).output().unwrap() +} + +/// The page count `info` reports for the extracted file. +fn pages_of(dir: &std::path::Path, pages: &str) -> usize { + let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_"))); + let info = Command::new(BIN).args(["info", out.to_str().unwrap()]).output().unwrap(); + let text = String::from_utf8_lossy(&info.stdout); + let n = text.split("\"pages\":").nth(1).and_then(|s| s.trim().split(|c: char| !c.is_ascii_digit()).next()).unwrap_or(""); + n.parse().unwrap_or_else(|_| panic!("no page count in {text}")) +} + +#[test] +fn pages_and_ranges_are_accepted_in_the_order_given() { + let dir = workdir("ranges"); + for (pages, count) in [("1-3", 3), ("5,1-2", 3), ("2-2", 1), ("1,3,5", 3), (" 4 - 5 , 1 ", 3)] { + let out = extract(&dir, pages); + assert!(out.status.success(), "{pages:?}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(pages_of(&dir, pages), count, "{pages:?}"); + } +} + +#[test] +fn bad_pages_and_ranges_are_named_as_typed() { + let dir = workdir("bad"); + for (pages, what) in [ + ("3-1", "bad page range \"3-1\""), + ("0", "bad page number \"0\""), + ("1-x", "bad page number \"1-x\""), + ("2-", "bad page number \"2-\""), + ("1-9999999", "bad page range \"1-9999999\""), + ] { + let out = extract(&dir, pages); + assert!(!out.status.success(), "{pages:?} succeeded"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains(what), "{pages:?}: {stderr}"); + } + // A page past the end is named as the user counts it (1-based), as a single page and as the + // first page a range reaches past the end. + for (pages, missing) in [("6", "page 6 does not exist"), ("9", "page 9 does not exist"), ("4-7", "page 6 does not exist")] { + let out = extract(&dir, pages); + assert!(!out.status.success(), "{pages:?} succeeded"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains(missing), "{pages:?}: {stderr}"); + } +} diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/run_report.rs new file mode 100644 index 0000000000..cf1e6b650d --- /dev/null +++ b/apps/pdfcraft-cli/tests/run_report.rs @@ -0,0 +1,71 @@ +//! `pdfcraft-cli run --script --report`: one JSON document for the whole run (#1891). + +use std::process::{Command, Output}; + +fn run_script(test: &str, steps: &serde_json::Value, extra: &[&str]) -> Output { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let script = dir.join("steps.json"); + std::fs::write(&script, steps.to_string()).unwrap(); + let out = + Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).current_dir(&dir).arg("run").arg("--script").arg(&script).args(extra).output().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + out +} + +fn blank() -> serde_json::Value { + serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } }) +} + +#[test] +fn a_successful_run_is_one_document_with_a_result_per_step() { + let steps = serde_json::json!([blank(), { "tool": "page_render", "args": { "doc": 1, "page": 1, "dpi": 10 } }]); + let o = run_script("report-ok", &steps, &["--report"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document"); + assert_eq!(report["ok"], true); + let steps = report["steps"].as_array().unwrap(); + assert_eq!(steps.len(), 2); + assert_eq!((steps[0]["step"].clone(), steps[0]["tool"].clone(), steps[0]["status"].clone()), (1.into(), "doc_create".into(), "ok".into())); + assert_eq!(steps[1]["result"][0]["image"], "png", "an image with no `out` is summarised: {report}"); +} + +#[test] +fn a_failing_step_is_named_and_the_rest_are_skipped() { + let steps = serde_json::json!([blank(), { "tool": "page_rotate", "args": { "doc": 1, "pages": [999], "degrees": 90 } }, { "tool": "doc_close", "args": { "doc": 1 } }]); + let o = run_script("report-fail", &steps, &["--report"]); + assert_eq!(o.status.code(), Some(1), "exit status is unchanged"); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(stderr.contains("step 2 (page_rotate)"), "{stderr}"); + let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document"); + assert_eq!(report["ok"], false); + let steps = report["steps"].as_array().unwrap(); + assert_eq!(steps.iter().map(|s| s["status"].as_str().unwrap()).collect::>(), ["ok", "error", "skipped"]); + assert!(steps[1]["error"].as_str().unwrap().contains("step 2 (page_rotate)"), "{report}"); + assert_eq!(steps[2]["tool"], "doc_close"); +} + +#[test] +fn a_step_without_a_tool_is_an_error_step() { + let o = run_script("report-notool", &serde_json::json!([{ "args": {} }]), &["--report"]); + assert_eq!(o.status.code(), Some(1)); + let report: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap(); + assert_eq!(report["steps"][0]["status"], "error"); + assert!(report["steps"][0]["tool"].is_null()); +} + +#[test] +fn without_the_flag_the_output_is_unchanged() { + let o = run_script("report-off", &serde_json::json!([blank()]), &[]); + assert!(o.status.success()); + let stdout = String::from_utf8_lossy(&o.stdout); + assert!(!stdout.contains("\"steps\""), "{stdout}"); +} + +#[test] +fn the_flag_needs_a_script() { + let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["run", "doc_create", "--report"]).output().unwrap(); + assert_eq!(o.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&o.stderr).contains("--report needs --script")); +} diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 4a185d1f92..762efbc7e1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -36,6 +36,11 @@ fmv-macos-events = "=0.1.0" wayland-client = "0.31" wayland-protocols = { version = "0.32", features = ["client"] } +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +# Whether the window is on Wayland, where it can't bring itself forward (src/single_instance.rs). +# Already in the tree through winit and eframe. +raw-window-handle = "0.6" + [dev-dependencies] # Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu). pollster = "1.0" diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index a1d2ad998d..c91c7b6b1f 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -213,9 +213,10 @@ fn main() -> eframe::Result { Err(e) => log::warn!("no log file: {e}"), } } - // Windows: a launch that only names files (an Outlook attachment, an Explorer double-click) - // hands them to the PdfCraft already running and exits (#282, #317). Any option means the - // caller wants this launch's own window. + // Windows, Linux and the BSDs: a launch that only names files (an Outlook attachment, an + // Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already + // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own + // window. let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), @@ -332,6 +333,18 @@ struct Launch { window_controls: Option, } +/// Whether the window is a native Wayland one (not X11 or XWayland). +#[cfg(all(unix, not(target_os = "macos")))] +fn native_wayland(cc: &eframe::CreationContext<'_>) -> bool { + use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; + cc.display_handle().is_ok_and(|handle| matches!(handle.as_raw(), RawDisplayHandle::Wayland(_))) +} + +#[cfg(not(all(unix, not(target_os = "macos"))))] +fn native_wayland(_: &eframe::CreationContext<'_>) -> bool { + false +} + fn app_creator<'a>( launch: Launch, started: Rc>, @@ -368,7 +381,7 @@ fn app_creator<'a>( app.os_events = Some(apple_events.connect(&cc.egui_ctx)); } if let Some(server) = instance { - app.os_events = Some(server.connect(&cc.egui_ctx)); + app.os_events = Some(server.connect(&cc.egui_ctx, native_wayland(cc))); } if let Some(file) = &control_file { let client = app.attach_control(&cc.egui_ctx); diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 9d15f5b26a..ecfa12f5c3 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -1,41 +1,50 @@ -//! One PdfCraft per Windows sign-in: a launch that only names files hands them to the PdfCraft -//! that is already running, where they open as tabs, and exits (#282, #317). +//! One PdfCraft per Windows sign-in or Linux/BSD display: a launch that only names files hands them +//! to the PdfCraft that is already running, where they open as tabs, and exits (#282, #317, #1726). //! -//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe ""`, so -//! until now every file got its own process and window. The first PdfCraft listens on a Unix -//! domain socket (Windows 10 1803 and later) in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of -//! a portable copy. The socket is named after the Windows session (`instance-Console.sock`), so -//! two sign-ins of the same user never open files in each other's windows, and it lives in the -//! user's own profile, so other accounts can't create, replace or reach it: nobody else can -//! intercept a launch or open files in this user's window. The exchange is one line each way, a -//! JSON request naming absolute paths and `ok`, and the running app opens the paths exactly as -//! File ▸ Open would. +//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe ""`, and +//! a file manager or `xdg-open` starts `pdfcraft ` the same way, so until now every file got +//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10 +//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy, +//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same +//! user never open files in each other's windows. On Linux and the BSDs it is in +//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a +//! launch on another display or seat opens its own window there. Either way it lives in a folder +//! only the user can reach, so other accounts can't create, replace or reach it: nobody else can +//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is +//! the app's own and is shared by every run of it, so the handoff works there too. The exchange is +//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the +//! paths exactly as File ▸ Open would. //! //! Every failure falls back to the old behaviour: a launch that can't hand its files over within //! a few seconds opens its own window. `--new-window`, any other option, or no files at all also -//! starts a window of its own, as before. Other platforms keep their current behaviour (macOS -//! already routes Finder opens to the running app through Apple events). +//! starts a window of its own, as before. macOS keeps its current behaviour (it already routes +//! Finder opens to the running app through Apple events). //! -//! The socket comes from `uds_windows`, already in the lockfile through zbus: `std` has no Unix -//! sockets on Windows, and calling Winsock directly would need `unsafe`, which the workspace -//! forbids. +//! On Windows the socket comes from `uds_windows`, already in the lockfile through zbus: `std` has +//! no Unix sockets on Windows, and calling Winsock directly would need `unsafe`, which the +//! workspace forbids. use std::sync::{Arc, Mutex, PoisonError}; -#[cfg(windows)] +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] use std::time::Duration; +#[cfg(all(unix, not(target_os = "macos")))] +use std::os::unix::net::{UnixListener, UnixStream}; +#[cfg(windows)] +use uds_windows::{UnixListener, UnixStream}; + use pdfcraft_ui_egui::{OsEvent, OsEventsFn}; /// Longest request read: one line of JSON, far longer than any real list of paths. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] const MAX_REQUEST: u64 = 1 << 20; /// How long either side waits for the other before giving up. -#[cfg(windows)] +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] const TIMEOUT: Duration = Duration::from_secs(5); /// What this launch should do. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] pub enum Claim { /// The running PdfCraft took the files: exit without a window. HandedOff, @@ -59,13 +68,13 @@ pub struct Server { } impl Server { - #[cfg_attr(not(windows), allow(dead_code))] + #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] fn new() -> Self { Self { inbox: Arc::default() } } /// Queue files from another launch and wake the UI. - #[cfg_attr(not(windows), allow(dead_code))] + #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] fn deliver(&self, paths: Vec) { let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); inbox.paths.extend(paths); @@ -76,8 +85,10 @@ impl Server { /// The queue the UI drains every frame (`PdfCraftApp::os_events`). Files that arrived before /// the window existed are in the first batch. A batch also brings the window forward, as the - /// new window it replaces would have been. - pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn { + /// new window it replaces would have been. A Wayland window can't bring itself forward (winit + /// ignores `Focus` there), so on `native_wayland` it asks for the user's attention instead, + /// which desktops show by highlighting it in the taskbar. + pub fn connect(&self, ctx: &egui::Context, native_wayland: bool) -> OsEventsFn { let wake = ctx.clone(); self.inbox.lock().unwrap_or_else(PoisonError::into_inner).wake = Some(Box::new(move || wake.request_repaint())); let inbox = Arc::clone(&self.inbox); @@ -89,13 +100,16 @@ impl Server { } ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + if native_wayland { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); + } vec![OsEvent::Open(paths)] }) } } /// The request a launch sends: `{"open":[…]}` and a newline. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] pub fn request(paths: &[String]) -> String { let mut line = serde_json::json!({ "open": paths }).to_string(); line.push('\n'); @@ -104,7 +118,7 @@ pub fn request(paths: &[String]) -> String { /// The paths a request names, or `None` when it isn't a request: anything but one JSON object /// whose `open` is a non-empty list of non-empty strings. -#[cfg_attr(not(windows), allow(dead_code))] +#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] pub fn parse_request(line: &str) -> Option> { let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?; let open = value.get("open")?.as_array()?; @@ -121,14 +135,14 @@ pub fn absolute(paths: &[String]) -> Vec { /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false /// when the launch asked for anything besides opening files, so it gets its own window. pub fn claim(files: &[String], may_hand_off: bool) -> Claim { - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] { match socket_path() { Some(path) => claim_at(&path, files, may_hand_off), None => Claim::Alone, } } - #[cfg(not(windows))] + #[cfg(not(any(windows, all(unix, not(target_os = "macos")))))] { let _ = (files, may_hand_off); Claim::Alone @@ -148,6 +162,28 @@ fn socket_path() -> Option { std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name)) } +/// The socket in `$XDG_RUNTIME_DIR/pdfcraft`; none when `$XDG_RUNTIME_DIR` is unset. Never `/tmp` +/// instead: other accounts can create files there, and so could listen in this user's place. +#[cfg(all(unix, not(target_os = "macos")))] +fn socket_path() -> Option { + let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let name = display_socket_name(var("WAYLAND_DISPLAY").or_else(|| var("DISPLAY")).as_deref()); + // The XDG base directory spec makes it the user's own (mode 0700), so the folder inside it is + // out of other accounts' reach whatever the umask. + var("XDG_RUNTIME_DIR").map(|d| std::path::PathBuf::from(d).join("pdfcraft").join(name)) +} + +/// The socket's file name for a display (`WAYLAND_DISPLAY`, else `DISPLAY`): a launch opens its +/// files on the display it was started on, never in a window on another seat, a nested +/// compositor or a remote X display. Letters, digits, `-`, `_` and `.` are kept, and of those the +/// last 32, where an absolute `WAYLAND_DISPLAY` (`/run/user/1000/wayland-1`) differs. +#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))] +fn display_socket_name(display: Option<&str>) -> String { + let kept: Vec = display.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(); + let display: String = kept.get(kept.len().saturating_sub(32)..).unwrap_or_default().iter().collect(); + if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") } +} + /// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The /// same user signed in twice (at the console and over Remote Desktop) gets one socket per session, /// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept. @@ -161,9 +197,9 @@ fn socket_name(session: Option<&str>) -> String { /// files selected in Explorer and opened at once) take turns: the first one is listening before /// the next one looks. Without it a launch could find the socket bound but not yet listening, /// fail to clear it ("Access is denied") and open a window of its own. The lock is on a file -/// beside the socket, which is never deleted; Windows releases it when the process ends, however -/// it ends. Best effort: without the lock the launch goes on as before. -#[cfg(windows)] +/// beside the socket, which is never deleted; the system releases it when the process ends, +/// however it ends. Best effort: without the lock the launch goes on as before. +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn election(socket: &std::path::Path) -> Option { if let Some(dir) = socket.parent() { let _ = std::fs::create_dir_all(dir); @@ -183,9 +219,8 @@ fn election(socket: &std::path::Path) -> Option { } /// [`claim`] with the socket at `path`. -#[cfg(windows)] +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim { - use uds_windows::{UnixListener, UnixStream}; // Released when this function returns: by then this launch listens, or has handed over. let _election = election(path); // Two launches can start together: the one that loses the race to bind hands over to the @@ -241,8 +276,8 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla } /// Send `files` over `stream` and wait for the running app's `ok`. -#[cfg(windows)] -fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Result<()> { +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> { use std::io::{BufRead, Read, Write}; stream.set_read_timeout(Some(TIMEOUT))?; stream.set_write_timeout(Some(TIMEOUT))?; @@ -254,8 +289,8 @@ fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Resu } /// Accept launches on a background thread for as long as the process runs. -#[cfg(windows)] -fn serve(listener: uds_windows::UnixListener) -> Claim { +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +fn serve(listener: UnixListener) -> Claim { let server = Server::new(); let inbox = Server { inbox: Arc::clone(&server.inbox) }; let started = std::thread::Builder::new().name("single-instance".into()).spawn(move || { @@ -278,8 +313,8 @@ fn serve(listener: uds_windows::UnixListener) -> Claim { } /// Read one launch's request, queue its files and confirm. -#[cfg(windows)] -fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> { +#[cfg(any(windows, all(unix, not(target_os = "macos"))))] +fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> { use std::io::{BufRead, Read, Write}; stream.set_read_timeout(Some(TIMEOUT))?; stream.set_write_timeout(Some(TIMEOUT))?; @@ -338,26 +373,40 @@ mod tests { // Before the window exists: queued, nothing to wake yet. server.deliver(vec!["a.pdf".into()]); let ctx = egui::Context::default(); - let mut poll = server.connect(&ctx); + let mut poll = server.connect(&ctx, false); server.deliver(vec!["b.pdf".into()]); let events = poll(); assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"])); assert!(poll().is_empty(), "each file is delivered once"); } - #[cfg(windows)] + #[test] + fn each_display_gets_its_own_socket_name() { + assert_eq!(display_socket_name(Some("wayland-0")), "instance-wayland-0.sock"); + assert_eq!(display_socket_name(Some(":0")), "instance-0.sock"); + assert_ne!(display_socket_name(Some(":1.0")), display_socket_name(Some(":10"))); + assert_ne!(display_socket_name(Some("wayland-0")), display_socket_name(Some("wayland-1"))); + // An absolute WAYLAND_DISPLAY keeps the end of its path, where nested compositors differ. + let nested = display_socket_name(Some("/home/someone/.cache/nested-compositor/run/wayland-1")); + assert!(nested.ends_with("wayland-1.sock") && !nested.contains('/'), "{nested}"); + assert_eq!(display_socket_name(Some("../../x/y")), "instance-....xy.sock", "never a path"); + assert_eq!(display_socket_name(None), "instance.sock"); + assert_eq!(display_socket_name(Some("")), "instance.sock"); + } + + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn socket(test: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); dir.join(socket_name(None)) } - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] #[test] fn a_second_launch_hands_its_files_to_the_first() { let path = socket("hand-off"); let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") }; - let mut poll = first.connect(&egui::Context::default()); + let mut poll = first.connect(&egui::Context::default(), false); assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff)); assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"])); // A launch with options keeps its own window and doesn't take over the socket. @@ -365,10 +414,10 @@ mod tests { assert!(poll().is_empty()); } - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] #[test] fn launches_started_together_share_one_window() { - // Several files selected in Explorer and opened with Enter: one process per file, all at once. + // Several files selected in Explorer or a file manager and opened at once: one process per file. let path = socket("together"); let barrier = Arc::new(std::sync::Barrier::new(8)); let launches: Vec<_> = (0..8) @@ -386,12 +435,12 @@ mod tests { assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over"); } - #[cfg(windows)] + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] #[test] fn a_socket_left_by_a_crashed_instance_is_replaced() { let path = socket("stale"); // Bind and drop: the file stays, nobody listens. - drop(uds_windows::UnixListener::bind({ + drop(UnixListener::bind({ std::fs::create_dir_all(path.parent().unwrap()).unwrap(); &path })); diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs new file mode 100644 index 0000000000..5422af59b5 --- /dev/null +++ b/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs @@ -0,0 +1,20 @@ +//! vendor/winit patch (12) (vendor/README.md). + +#[test] +fn winit_lets_macos_title_bar_drags_reach_the_app() { + // On macOS the tab strip is the title bar: a transparent title bar over a full-size content + // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit + // moved the whole window for a drag that began on a document tab, and tabs could not be + // reordered. vendor/winit answers with the window's `movable_by_window_background` (NO unless + // asked); the empty strip still moves the window through `ViewportCommand::StartDrag`. A + // re-vendored copy without the patch would silently bring the bug back: re-apply the patch, or + // drop the copy once winit has the fix. + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap(); + let (_, method) = view.split_once("#[method(mouseDownCanMoveWindow)]").expect("a title-bar drag moves the window again"); + let body = method.split_once("\n }\n").map_or(method, |(body, _)| body); + assert!(body.contains("movable_by_window_background"), "mouseDownCanMoveWindow no longer follows the window's setting"); + // The strip still moves the window itself, from its empty background. + let chrome = std::fs::read_to_string(root.join("crates/ui-egui/src/chrome.rs")).unwrap(); + assert!(chrome.contains("ViewportCommand::StartDrag"), "the empty tab strip no longer moves the window"); +} diff --git a/contributors/people.toml b/contributors/people.toml index eb0e008d46..268df23a2f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -132,3 +132,11 @@ display_name = "Jason Brashear" [people.alexanderalber] real_name = "Alexander Alber" + +[people.mtlcl410] +display_name = "mtlcl410" +sync_github_name = false + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 71bc90a038..40a962b466 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -140,6 +140,22 @@ fn mcids(d: &Dict) -> Vec { out } +/// The page's named property lists (`/Resources /Properties`), which a `BDC` can name instead of +/// writing its property list inline. +fn page_properties(doc: &Document, page: &Dict) -> Dict { + let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + res.get(b"Properties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default() +} + +/// The marked-content id a `BDC` opens, whether its property list is inline or named in `props`. +fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option { + match op.operands.get(1)? { + Object::Dict(d) => d.get(b"MCID").and_then(Object::as_int), + Object::Name(n) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))), + _ => None, + } +} + pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; match &*doc.resolve(c) { @@ -170,6 +186,7 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; let mut marks: Vec = Vec::new(); let mut bbox = None; @@ -190,9 +207,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push( - matches!(op.operands.get(1), Some(Object::Dict(d)) if d.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))), - ), + b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), b"EMC" => { marks.pop(); } @@ -295,12 +310,11 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> let pages = pdfcraft_model::pages(doc); let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page)); if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { + let props = page_properties(doc, &page.dict); let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; let mut changed = false; for op in ops.iter_mut() { - let ours = op.op == b"BDC" - && matches!(op.operands.get(1), Some(Object::Dict(m)) if m.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))); - if ours { + if op.op == b"BDC" && bdc_mcid(doc, &props, op).is_some_and(|n| ids.contains(&n)) { *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); changed = true; } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b0ace2c034..6f85b8cbff 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -624,6 +624,62 @@ fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() { } } +/// `figures_doc` with its property lists named in the page resources (one inline, one indirect) +/// instead of written inline in the content. +fn named_figures_doc() -> Document { + pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 \ + /Resources << /XObject << /Im 10 0 R >> /Properties << /P0 << /MCID 0 >> /P1 11 0 R /Other << /Note (kept) >> >> >> >>" + .into(), + stream( + "/Figure /P0 BDC q 40 0 0 20 10 150 cm /Im Do Q EMC \ + /Photo /P1 BDC 100 100 50 30 re f EMC", + ), + "<< /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >>".into(), + "<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(), + "<< /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >>".into(), + "<< /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >>".into(), + "<< /Nums [0 [7 0 R 8 0 R]] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + "<< /MCID 1 >>".into(), + ], + "", + ) +} + +#[test] +fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() { + let (mut inline, mut named) = (figures_doc(), named_figures_doc()); + let place = |doc: &Document| figures(doc).into_iter().map(|f| (f.obj, f.page, f.alt, f.bbox)).collect::>(); + assert_eq!(place(&named), place(&inline), "both figures, placed where their content is drawn"); + for doc in [&mut inline, &mut named] { + mark_decorative(doc, pdfcraft_cos::ObjRef::new(8, 0)).unwrap(); + let left = figures(doc); + assert_eq!((left.len(), left[0].obj.num, left[0].bbox), (1, 7, Some([10.0, 150.0, 50.0, 170.0]))); + let pages = pdfcraft_model::pages(doc); + let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap()); + let Object::Stream(c) = &*c else { panic!("one content stream") }; + let text = String::from_utf8(c.decoded().unwrap()).unwrap(); + assert!(text.contains("/Artifact BMC") && !text.contains("/P1") && !text.contains("/MCID 1"), "{text}"); + assert!(text.contains("/Im Do"), "the drawing itself stays: {text}"); + let nums = doc.get(pdfcraft_cos::ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned()); + assert_eq!( + nums, + Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), Object::Null])])), + "the parent tree no longer points MCID 1 at the removed figure" + ); + let r = check(doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None }); + assert_eq!(r.result(Rule::TaggedContent).unwrap().status, Status::Passed, "{:?}", r.results); + } + let props = pdfcraft_model::pages(&named)[0].dict.get(b"Resources").and_then(|r| named.resolve(r).as_dict().cloned()); + let props = props.and_then(|r| r.get(b"Properties").and_then(|p| named.resolve(p).as_dict().cloned())).unwrap(); + assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were"); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) } diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 5621f4638b..f4973aa021 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -1946,6 +1946,47 @@ pub fn set_style( redraw_or_restore(doc, r, before) } +/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the +/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new +/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt. +pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> { + let (_, r) = annot_ref(doc, page, index)?; + unlocked(doc, r)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"FreeText") { + return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into())); + } + if !size.is_finite() || !(1.0..=400.0).contains(&size) { + return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into())); + } + refuse_foreign_text(doc, &d)?; + let before = d; + doc.update_dict(r, |d| { + let (col, _) = appearance::parse_da(d); + d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes())); + rich_text(d); + touch(d, meta); + })?; + let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default(); + let result = match fitted_box(doc, r, &text) { + Some(fit) => { + let cur = annot_dict(doc, r); + let nums: Vec = + cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + // Keep the top and sides; only the bottom edge moves, and only downwards. + match four(&nums).map(normalize) { + Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false), + _ => apply_text_box(doc, r, fit, meta, false), + } + } + None => redraw_or_restore(doc, r, before.clone()), + }; + if result.is_err() { + doc.set(r, Object::Dict(before)); + } + result +} + /// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, /// ovals, polygons and clouds, §12.5.6.8–9). pub fn fillable(subtype: &str) -> bool { @@ -2180,6 +2221,8 @@ pub struct Props { pub fillable: bool, /// `/IC`, the interior colour (`None`: no fill). pub fill: Option, + /// Text boxes, typewriter text and callouts: the `/DA` font size in points. + pub font_size: Option, } /// The current properties of the comment at `(page, index)`. @@ -2205,6 +2248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, fillable: fillable(&subtype), fill: fill_of(d), + font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1), subtype, endings: line_endings_of(d), }) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 2ae46a2467..faed409abb 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1273,3 +1273,38 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { assert!(!a.is_empty()); assert_eq!(a, b, "the reused appearance is byte-identical"); } + +/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box +/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused. +#[test] +fn text_box_font_size_changes_the_da_and_grows_the_box() { + let mut doc = fixture(); + let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); + set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); + let height = |doc: &Document| { + let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + rect[3] - rect[1] + }; + let before = height(&doc); + set_font_size(&mut doc, 0, t, 24.0, &meta("")).unwrap(); + let doc = reopen(&doc); + let d = &list(&doc, 0)[t]; + assert_eq!(text(d, b"DA"), "1 0 0 rg /Helv 24 Tf"); + assert!(text(d, b"DS").contains("24pt") && text(d, b"RC").contains("font-size:24pt")); + assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(24.0)); + assert!(height(&doc) > before, "the box grows"); + let mut doc = doc; + let grown = height(&doc); + set_font_size(&mut doc, 0, t, 6.0, &meta("")).unwrap(); + assert!(height(&doc) >= grown, "the box never shrinks"); + for bad in [0.0, 0.5, 401.0, f64::NAN, f64::INFINITY] { + assert!(matches!(set_font_size(&mut doc, 0, t, bad, &meta("")), Err(AnnotError::Invalid(_))), "{bad}"); + } + assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(6.0)); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [50.0, 50.0], icon: NoteIcon::Note }), &meta("t")); + if let Ok(i) = note { + assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err()); + assert_eq!(props(&doc, 0, i).unwrap().font_size, None); + } +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 3d3a46c0fa..32dec0bf2a 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -188,3 +188,19 @@ unapplied with a warning. `doc_batch` runs conversion, OCR and compression over files and optionally recursive folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. + +## Viewer navigation history + +A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through +`pdfcraft-cli ui --control FILE command id=view.previous_view` and +`pdfcraft-cli ui --control FILE command id=view.next_view`. `commands` reports whether each action +is enabled for the active document. These are the same actions as the rail buttons, View → Page +navigation, and Ctrl+[ / Ctrl+] (⌘[ / ⌘] on macOS). + +Explicit page jumps, including search-result selection and Next/Previous match, enter history +when the page changes. Automatic search refresh, continuous scrolling, and within-page scroll +movement do not. Existing page-step commands and single-page wheel turns retain their history +behavior. Back/Forward +returns to page tops, does not restore zoom, does not switch documents, and is not saved across +restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate +navigation-history tool; use the opt-in UI control channel to navigate the displayed document. diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 6a0e6a195b..60c1d2b3f3 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -202,6 +202,10 @@ fn review_html(record: &Value) -> Result { Ok(html) } +/// Tags in one page of `accessibility_tags`, by default and at most. +const TAG_PAGE: usize = 200; +const MAX_TAG_PAGE: usize = 1000; + impl Automation { pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; @@ -441,11 +445,39 @@ impl Automation { self.accessibility_figures(a) } + /// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages + /// (1-based) or depths. `count` is how many match; `next` is where the following page starts. pub(crate) fn accessibility_tags(&self, a: &Args) -> Result { - let tags: Vec = self + let offset = usize::try_from(a.opt_int("offset")?.unwrap_or(0)).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(TAG_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_TAG_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_TAG_PAGE}")))?; + let types: Option> = a.get("types").map(|_| a.strs("types")).transpose()?; + let pages: Option> = a + .opt_ints("pages")? + .map(|p| p.into_iter().map(|n| usize::try_from(n).ok().filter(|n| *n >= 1).map(|n| n - 1)).collect::>>()) + .map(|p| p.ok_or_else(|| ToolError::InvalidArgs("pages must be 1-based page numbers".into()))) + .transpose()?; + let max_depth = a + .opt_int("max_depth")? + .map(|d| usize::try_from(d).map_err(|_| ToolError::InvalidArgs("max_depth must be 0 or more".into()))) + .transpose()?; + let matching: Vec<_> = self .doc(a)? .tags() .into_iter() + .filter(|t| types.as_ref().is_none_or(|ty| ty.contains(&t.kind.as_str()))) + .filter(|t| pages.as_ref().is_none_or(|p| t.page.is_some_and(|page| p.contains(&page)))) + .filter(|t| max_depth.is_none_or(|d| t.depth <= d)) + .collect(); + let count = matching.len(); + let next = offset.checked_add(limit).filter(|n| *n < count); + let tags: Vec = matching + .into_iter() + .skip(offset) + .take(limit) .map(|t| { json!({ "object": t.object.num, "generation": t.object.generation, "type": t.kind, @@ -455,7 +487,7 @@ impl Automation { }) }) .collect(); - Ok(json!({ "count": tags.len(), "tags": tags })) + Ok(json!({ "count": count, "tags": tags, "next": next })) } pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 10036d7e61..302688f729 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -619,6 +619,12 @@ impl Automation { let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) }; edits.push(Edit::FillAnnotation { page, index, fill }); } + if let Some(size) = a.opt_num("font_size")? { + if !(1.0..=400.0).contains(&size) { + return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into())); + } + edits.push(Edit::FontSizeAnnotation { page, index, size }); + } if let Some(r) = a.nums::<4>("rect")? { edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); } @@ -627,7 +633,7 @@ impl Automation { edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] }); } if edits.is_empty() { - return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, rect or move".into())); + return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into())); } let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } }; self.apply(a, edit) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 7b8e166d81..71f66e035d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -33,10 +33,11 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands}; -use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes}; +use pdfcraft_platform::staging::{StagingName, staging_suffixes}; use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; use serde_json::{Value, json}; +pub use pdfcraft_render::Pixels; pub use tools::{ToolDef, tools}; /// One piece of a tool's result. @@ -140,20 +141,28 @@ impl Automation { self } + /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); + self + } + pub fn session(&self) -> &Session { &self.session } /// Save `bytes` that a caller writes on the session's behalf, such as `pdfcraft-cli run` /// saving a rendered page. With a root, it is confined like a tool's own output (a relative - /// path resolves inside it) and written atomically. Without one, the path is written as - /// given, so `/dev/stdout` and the like still work. Returns where the file went. + /// path resolves inside it). Written atomically either way (#2166), except to something + /// that is not a regular file, such as `/dev/stdout` or a pipe, which is written as given. + /// Returns where the file went. pub fn write_output(&self, path: &str, bytes: &[u8]) -> Result { let target = self.resolve(path, true)?; - if self.root.is_some() { - write_atomic(&target, bytes)?; - } else { + let special = self.root.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir()); + if special { std::fs::write(&target, bytes).map_err(|e| failed(format!("{path}: {e}")))?; + } else { + write_atomic(&target, bytes)?; } Ok(target) } @@ -353,7 +362,9 @@ impl Automation { self.apply(&a, Edit::DuplicatePages { pages })? } "page_set_box" => self.page_set_box(&a)?, + "page_transition_set" => self.page_transition_set(&a)?, "doc_create" => self.doc_create(&a)?, + "xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?, "doc_flatten" => { let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true)); if !comments && !fields { @@ -467,6 +478,15 @@ impl Automation { ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) } "rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32), + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = + <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } "flip_horizontal" => ImageEdit::Flip { horizontal: true }, "flip_vertical" => ImageEdit::Flip { horizontal: false }, "delete" => ImageEdit::Delete, @@ -821,7 +841,8 @@ impl Automation { .session .bookmark_page(id, offset, limit) .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; - Ok(json!({ "bookmarks": nest_rows(&page.rows), "next": page.next, "truncated": page.truncated })) + let pages = &self.doc(a)?.info.pages; + Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated })) } fn doc(&self, a: &Args) -> Result<&Document> { @@ -1205,7 +1226,7 @@ impl Automation { let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?; let mut files = Vec::new(); for (k, img) in out.images.iter().enumerate() { - let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1)); + let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len())); write_atomic(&path, &img.data)?; files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height })); } @@ -1287,6 +1308,58 @@ impl Automation { Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?)) } + fn page_transition_set(&mut self, a: &Args) -> Result { + use pdfcraft_engine::{Transition, TransitionSpec}; + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let transition = match a.opt_str("style")? { + None | Some("none") => None, + Some(style) => { + let mut t = Transition::new(style); + if let Some(d) = a.opt_num("duration")? { + t.duration = d as f32; + } + if let Some(dm) = a.opt_str("dimension")? { + t.horizontal = match dm { + "horizontal" => true, + "vertical" => false, + other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))), + }; + } + if let Some(m) = a.opt_str("motion")? { + t.inward = match m { + "inward" => true, + "outward" => false, + other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))), + }; + } + match a.get("direction") { + None => {} + Some(Value::String(s)) if s == "none" => t.direction = None, + Some(v) => { + t.direction = Some( + v.as_u64() + .and_then(|d| u16::try_from(d).ok()) + .ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?, + ); + } + } + if let Some(s) = a.opt_num("scale")? { + t.scale = s as f32; + } + if let Some(b) = a.opt_bool("opaque")? { + t.opaque = b; + } + Some(t) + } + }; + let display = a.opt_num("display")?.map(|s| s as f32); + self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } }) + } + fn page_set_box(&mut self, a: &Args) -> Result { use pdfcraft_engine::{BoxSpec, PageBox}; let doc = self.doc(a)?; @@ -1711,6 +1784,27 @@ impl Automation { Ok(Content::Png { data, width: out.width, height: out.height }) } + /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels + /// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas: + /// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted + /// work on every scroll. It uses the same renderer, and the same per-document cache, as + /// `page_render`. + pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> { + if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) { + return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0))); + } + let id = DocId(doc); + let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len(); + if page < 1 || page > n { + return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages"))); + } + let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false }); + if let Some(e) = out.error { + return Err(failed(format!("page {page} could not be rendered: {e}"))); + } + Ok((out.width, out.height, out.rgba)) + } + fn page(&self, a: &Args) -> Result { let n = self.doc(a)?.info.pages.len(); let p = a.int("page")?; @@ -2107,7 +2201,7 @@ impl Args<'_> { /// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the /// next level down in `children`, and paths and pages are 1-based. -fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { +fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> Vec { // The open rows, outermost first, with their depth and the children gathered so far. let mut open: Vec<(usize, Value, Vec)> = Vec::new(); let mut roots = Vec::new(); @@ -2117,7 +2211,10 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { close_row(&mut open, &mut roots); } let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); - let fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) { + fields["destination"] = destination; + } open.push((depth, fields, Vec::new())); } while !open.is_empty() { @@ -2126,6 +2223,36 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { roots } +/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page +/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its +/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the +/// reader's position and zoom). `None` for a destination without a type. +pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option { + use pdfcraft_render::DestView; + let points = |left: Option, top: Option| { + let [x, y] = page.dest_fraction(left, top, 0); + let round = |v: f32| f64::from((v * 100.0).round() / 100.0); + (x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height))) + }; + Some(match view { + DestView::Top => return None, + DestView::Xyz { left, top, zoom } => { + let (x, y) = points(*left, *top); + json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) }) + } + DestView::Fit => json!({ "fit": "Fit" }), + DestView::FitH { top } => { + let (x, y) = points(None, *top); + json!({ "fit": "FitH", "left": x, "top": y }) + } + DestView::FitV { left } => { + let (x, y) = points(*left, None); + json!({ "fit": "FitV", "left": x, "top": y }) + } + DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }), + }) +} + /// Closes the innermost open row: it joins the row above it as a child, or the top level. fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { if let Some((_, mut fields, children)) = open.pop() { @@ -2248,6 +2375,13 @@ fn info(d: &Document) -> Value { }, })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), + // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with + // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. + "transitions": i.transitions.iter().map(|t| json!({ + "page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration, + "horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque, + "display": t.display, + })).collect::>(), "attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::>(), // `substitute` names the face standing in for a program the document does not embed, and // `substitution` says why. Both are null for an embedded font: nothing stands in. A null @@ -2370,32 +2504,20 @@ fn write_atomic_synced( suffixes: impl IntoIterator, sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>, ) -> Result<()> { - use std::io::Write; // A folder can't be replaced, and staging beside it could land outside the root: "." names // the root itself, whose parent isn't ours to write in. if path.is_dir() { return Err(failed(format!("{}: is a folder, not a file", path.display()))); } let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); - let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; + path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?; std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - let (tmp, file) = - create_staging(dir, &name.to_string_lossy(), StagingName::SuffixThenTag, suffixes).map_err(|e| failed(format!("{}: {e}", dir.display())))?; - // Closed at the end of the block, before the rename. - let written = { - let mut file = file; - // Surface delayed writeback failures before replacing the destination or marking - // a document clean. The handle is closed before rename (required on Windows). - file.write_all(bytes).and_then(|()| sync(&file)) - }; - if let Err(e) = written { - let _ = std::fs::remove_file(&tmp); - return Err(failed(format!("{}: {e}", tmp.display()))); - } - std::fs::rename(&tmp, path).map_err(|e| { - let _ = std::fs::remove_file(&tmp); - failed(format!("{}: {e}", path.display())) - }) + // The writer itself is the platform crate's, shared with the desktop app and the CLI + // (#2166): the staging file is written, flushed (delayed writeback failures surface before + // the destination is replaced or a document marked clean), closed and renamed; a failure at + // any step removes it and leaves the destination as it was. + pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync) + .map_err(|e| failed(format!("{}: {e}", path.display()))) } #[cfg(test)] diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index c8ef9fc9d4..9a810ed1c8 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -65,6 +65,7 @@ impl Automation { pub(crate) fn link_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; + let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect(); let items: Vec = doc .links .iter() @@ -73,10 +74,25 @@ impl Automation { let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32)); let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); - match &l.action { - LinkAction::Page(p) => v["to_page"] = json!(p + 1), - LinkAction::Uri(u) => v["url"] = json!(u), - LinkAction::Other(o) => v["other"] = json!(o), + // Where an in-document link goes, as the viewer resolves it: named destinations + // included, with the position on the page. + let target = targets.get(&(l.page, l.index)).and_then(|t| match t { + LinkTarget::Page(page, view) => Some((*page, view)), + _ => None, + }); + match (&l.action, target) { + (LinkAction::Uri(u), _) => v["url"] = json!(u), + (action, Some((page, view))) => { + v["to_page"] = json!(page + 1); + if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) { + v["destination"] = d; + } + if matches!(action, LinkAction::Other(_)) { + v["named"] = json!(true); + } + } + (LinkAction::Page(p), None) => v["to_page"] = json!(p + 1), + (LinkAction::Other(o), None) => v["other"] = json!(o), } v }) diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index e9b16fc8ad..9ecf7d3d1f 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -125,7 +125,18 @@ impl Automation { bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", "))) })?, }; - let settings = print::Settings { pages, paper, orientation, layout, content }; + // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when + // `grayscale` is also asked for. + let raster = if a.opt_bool("as_image")?.unwrap_or(false) { + let dpi = a.opt_num("dpi")?.unwrap_or(300.0); + if !(72.0..=600.0).contains(&dpi) { + return Err(bad("dpi must be between 72 and 600")); + } + Some(print::Raster { dpi, grayscale: a.opt_bool("grayscale")?.unwrap_or(false) }) + } else { + None + }; + let settings = print::Settings { pages, paper, orientation, layout, content, raster }; let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len(); let bytes = self.session.print_pdf(id, &settings).map_err(failed)?; diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 2d0281fc00..94fc4bdec8 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -17,6 +17,62 @@ fn time(secs: i64) -> sign::Time { sign::Time::from_unix(secs) } +/// The longest label `sign_document` takes (characters): a label is a few words. +const MAX_LABEL_CHARS: usize = 200; + +/// `appearance`: which items the visible signature shows; those not given keep the default +/// (name, date and labels on; reason, location and distinguished name off). +fn appearance_arg(v: &Value) -> Result { + const KEYS: &str = "name, date, reason, location, distinguished_name, labels"; + let o = v.as_object().ok_or_else(|| bad(format!("appearance must be an object of true/false items ({KEYS})")))?; + let mut shown = sign::Appearance::default(); + for (key, value) in o { + let on = value.as_bool().ok_or_else(|| bad(format!("appearance.{key} must be true or false")))?; + let item = match key.as_str() { + "name" => &mut shown.name, + "date" => &mut shown.date, + "reason" => &mut shown.reason, + "location" => &mut shown.location, + "distinguished_name" => &mut shown.distinguished_name, + "labels" => &mut shown.labels, + other => return Err(bad(format!("unknown appearance item {other:?} ({KEYS})"))), + }; + *item = on; + } + Ok(shown) +} + +/// `labels`: the words of the visible signature's labels; those not given keep the English. Each +/// must be drawable in the signature's font. Nobody sees a preview here, so a label it can't draw +/// is refused even when this signature doesn't show it. +fn labels_arg(v: &Value) -> Result { + const KEYS: &str = "signed_by, distinguished_name, reason, location, date"; + let o = v.as_object().ok_or_else(|| bad(format!("labels must be an object of strings ({KEYS})")))?; + let mut labels = sign::AppearanceLabels::default(); + for (key, value) in o { + let text = value.as_str().ok_or_else(|| bad(format!("labels.{key} must be a string")))?; + if text.chars().count() > MAX_LABEL_CHARS { + return Err(bad(format!("labels.{key} is longer than {MAX_LABEL_CHARS} characters"))); + } + let label = match key.as_str() { + "signed_by" => &mut labels.signed_by, + "distinguished_name" => &mut labels.distinguished_name, + "reason" => &mut labels.reason, + "location" => &mut labels.location, + "date" => &mut labels.date, + other => return Err(bad(format!("unknown label {other:?} ({KEYS})"))), + }; + *label = text.to_string(); + } + if let Some((field, c)) = labels.first_undrawable() { + return Err(bad(format!( + "labels.{field}: {}", + pdfcraft_engine::undrawable_message(&format!("the {field} label of the visible signature"), c) + ))); + } + Ok(labels) +} + pub(crate) fn cert_json(c: &Certificate) -> Value { json!({ "name": c.display_name(), @@ -89,8 +145,8 @@ impl Automation { return Err(bad("name must not be empty")); } let password = a.str("password")?; - if password.chars().count() < 6 { - return Err(bad("the password must have at least 6 characters")); + if password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS { + return Err(bad(format!("the password must have at least {} characters", sign::pkcs12::MIN_PASSWORD_CHARS))); } let path = self.resolve(a.str("path")?, true)?; let key = match a.opt_str("key")?.unwrap_or("rsa2048") { @@ -177,6 +233,17 @@ impl Automation { Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))), Some(g) => Some(self.signature_graphic(g)?), }; + let visible = rect.is_some() || field.is_some(); + let appearance = match a.get("appearance") { + None | Some(Value::Null) => sign::Appearance::default(), + Some(_) if !visible => return Err(bad("appearance needs a visible signature: pass rect or field")), + Some(v) => appearance_arg(v)?, + }; + let appearance_labels = match a.get("labels") { + None | Some(Value::Null) => sign::AppearanceLabels::default(), + Some(_) if !visible => return Err(bad("labels needs a visible signature: pass rect or field")), + Some(v) => labels_arg(v)?, + }; let out = self.resolve(a.str("out")?, true)?; let opts = SignOptions { field, @@ -186,6 +253,8 @@ impl Automation { location: a.opt_str("location")?.map(str::to_string), contact: a.opt_str("contact")?.map(str::to_string), certify, + appearance, + appearance_labels, graphic, ..SignOptions::default() }; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index dcc06f5f03..4b61820271 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -133,7 +133,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, attachments, fonts, security and repair notes.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -229,7 +229,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "at": { "type": "integer", "minimum": 1 }, "width": { "type": "number", "description": "Points." }, "height": { "type": "number", "description": "Points." } }), &["doc", "at"], )), - t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Undoable.") + t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Its bookmarks come along, collapsed under one bookmark named after the file, placed among the document's bookmarks where the pages went. Undoable.") .cmd("page.insert") .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "at": { "type": "integer", "minimum": 1 }, "pages": pages("of the source file (default: all)") }), &["doc", "path", "at"])), t("page_extract", "Extract pages", "Copy pages into a new PDF (links, bookmarks, fields and layers that belong to them come along). separate: true writes each page as its own file into out_dir; delete: true removes the pages from this document afterwards (undoable).") @@ -372,8 +372,7 @@ pub fn tools() -> Vec { "Field properties", "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) - .with({ - let mut input = schema( + .with(schema( json!({ "doc": doc(), "field": { "type": "string", "description": "The field's current name." }, @@ -399,10 +398,7 @@ pub fn tools() -> Vec { "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc"], - ); - input["oneOf"] = json!([{ "required": ["field"] }, { "required": ["fields"] }]); - input - }), + )), t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.") .with(schema( json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), @@ -512,6 +508,8 @@ pub fn tools() -> Vec { "collate": { "type": "boolean" }, "duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] }, "grayscale": { "type": "boolean" }, + "as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." }, + "dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." }, "options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." }, }), &["doc"], @@ -522,7 +520,7 @@ pub fn tools() -> Vec { t( "page_add_text", "Add text to a page", - "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.", + "Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.", ) .cmd("edit.text") .with(schema( @@ -679,7 +677,7 @@ pub fn tools() -> Vec { "country": { "type": "string", "description": "Two-letter country code." }, "key": { "type": "string", "enum": ["rsa2048", "rsa3072", "rsa4096", "p256"] }, "years": { "type": "integer", "minimum": 1, "maximum": 50 }, - "password": { "type": "string", "minLength": 6 }, + "password": { "type": "string", "minLength": pdfcraft_engine::sign::pkcs12::MIN_PASSWORD_CHARS }, "path": { "type": "string", "description": "The .p12 file to write." }, }), &["name", "password", "path"], @@ -692,7 +690,7 @@ pub fn tools() -> Vec { .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), - t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") .cmd("sign.digital") .with(schema( json!({ @@ -717,6 +715,31 @@ pub fn tools() -> Vec { "minProperties": 1, "maxProperties": 1, }, + "appearance": { + "type": "object", + "description": "What a visible signature shows; items not given keep their default.", + "properties": { + "name": { "type": "boolean", "description": "Default true." }, + "date": { "type": "boolean", "description": "Default true." }, + "reason": { "type": "boolean", "description": "Default false." }, + "location": { "type": "boolean", "description": "Default false." }, + "distinguished_name": { "type": "boolean", "description": "Default false." }, + "labels": { "type": "boolean", "description": "Default true." }, + }, + "additionalProperties": false, + }, + "labels": { + "type": "object", + "description": "The words of a visible signature's labels (at most 200 characters each, Western European characters only); labels not given stay English.", + "properties": { + "signed_by": { "type": "string", "description": "Above the name. Default \"Digitally signed by\"." }, + "distinguished_name": { "type": "string", "description": "Before the certificate's subject. Default \"DN: \"." }, + "reason": { "type": "string", "description": "Default \"Reason: \"." }, + "location": { "type": "string", "description": "Default \"Location: \"." }, + "date": { "type": "string", "description": "Default \"Date: \"." }, + }, + "additionalProperties": false, + }, "out": { "type": "string", "description": "Where to save the signed document." }, }), &["doc", "id", "out"], @@ -755,13 +778,14 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }), &["doc"], )), - t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( + t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema( comment_ref(json!({ "contents": { "type": "string" }, "color": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0 }, "fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." }, + "font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "move": point(), })), @@ -800,6 +824,26 @@ pub fn tools() -> Vec { t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") .cmd("page.duplicate") .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), + t( + "page_transition_set", + "Set page transitions", + "Set or remove the transition and display time pages play in a full-screen presentation (ISO 32000-2 §12.4.4; Acrobat's Organize ▸ Page Transitions). style: Split, Blinds, Box, Wipe, Dissolve, Glitter, R (replace, no effect), Fly, Push, Cover, Uncover or Fade, or none to remove the transition; duration in seconds (default 1); dimension horizontal/vertical (Split, Blinds); motion inward/outward (Split, Box, Fly); direction 0, 90, 180, 270, 315 or none (Wipe, Glitter, Fly, Cover, Uncover, Push); scale and opaque for Fly. display: seconds the page stays before advancing; left out, the page waits for the presenter. doc_info lists the result under transitions.", + ) + .with(schema( + json!({ + "doc": doc(), + "pages": pages("to change (default: all)"), + "style": { "type": "string", "enum": ["none", "Split", "Blinds", "Box", "Wipe", "Dissolve", "Glitter", "R", "Fly", "Push", "Cover", "Uncover", "Fade"] }, + "duration": { "type": "number", "minimum": 0 }, + "dimension": { "type": "string", "enum": ["horizontal", "vertical"] }, + "motion": { "type": "string", "enum": ["inward", "outward"] }, + "direction": { "description": "0, 90, 180, 270, 315 or \"none\"" }, + "scale": { "type": "number", "exclusiveMinimum": 0 }, + "opaque": { "type": "boolean" }, + "display": { "type": "number", "minimum": 0 }, + }), + &["doc"], + )), t( "page_set_box", "Set page boxes / crop", @@ -948,10 +992,20 @@ pub fn tools() -> Vec { t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.") .cmd("a11y.alt_text") .with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])), - t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.") + t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.") .ro() .cmd("a11y.tags") - .with(schema(json!({ "doc": doc() }), &["doc"])), + .with(schema( + json!({ + "doc": doc(), + "offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Tags in the page (default 200)" }, + "types": { "type": "array", "items": { "type": "string" }, "description": "Only tags of these types, as listed in type (such as H1, H2, Table)" }, + "pages": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": "Only tags on these pages" }, + "max_depth": { "type": "integer", "minimum": 0, "description": "Only tags at most this deep (0 = top level)" } + }), + &["doc"], + )), t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, @@ -1079,7 +1133,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1175,14 +1229,14 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "path": { "type": "string" }, @@ -1258,9 +1312,16 @@ pub fn tools() -> Vec { }), &["doc", "path"], )), - t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Undoable until saved.") + t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Flattening the fields of an XFA form also takes the XFA out (its template, data and scripts), so Adobe's viewers show the baked pages rather than laying the form out again. Undoable until saved.") .destructive() .with(schema(json!({ "doc": doc(), "comments": { "type": "boolean", "description": "Default true." }, "fields": { "type": "boolean", "description": "Default true." } }), &["doc"])), + t( + "xfa_to_static", + "Convert an XFA form to a static form", + "Make an XFA form an ordinary PDF form: the XFA packets (template, data, scripts) and PdfCraft's private keys go; the pages as laid out and the fields with their values stay, so every viewer shows the same form and none lays it out again. Its scripts no longer run. Undoable until saved.", + ) + .destructive() + .with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e3eaf4ce52..f5068b476f 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -115,6 +115,105 @@ fn auto(dir: &Path) -> Automation { Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000) } +/// Full-screen presentation settings (`/Trans`, `/Dur`) reach `doc_info`, 1-based pages, only +/// for the pages that carry them. +#[test] +fn doc_info_lists_page_transitions_and_display_times() { + let dir = workdir("transitions"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Trans << /S /Fade /D 0.5 >> /Dur 4 >>".to_vec(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("slides.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "slides.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let t = info["transitions"].as_array().unwrap(); + assert_eq!(t.len(), 1, "{}", info["transitions"]); + assert_eq!( + (t[0]["page"].as_u64(), t[0]["style"].as_str(), t[0]["duration"].as_f64(), t[0]["display"].as_f64()), + (Some(2), Some("Fade"), Some(0.5), Some(4.0)), + "{}", + t[0] + ); +} + +/// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and +/// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`, +/// so this does not lean on `doc_info` listing them.) +#[test] +fn page_transitions_are_set_and_removed_through_the_tools() { + use pdfcraft_cos::Object; + /// `key` of 0-based `page` in the saved file `name`, resolved. + fn entry(dir: &Path, name: &str, page: usize, key: &[u8]) -> Option { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).ok()?)).ok()?; + let root = d.root()?; + let pages = d.get(root).as_dict()?.reference(b"Pages")?; + let kids = d.get(pages).as_dict()?.get(b"Kids")?.clone(); + let kid = d.resolve(&kids).as_array()?.get(page)?.clone(); + let o = d.resolve(&kid).as_dict()?.get(key)?.clone(); + Some((*d.resolve(&o)).clone()) + } + let dir = workdir("transitions-set"); + std::fs::write(dir.join("a.pdf"), fixture(3)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_transition_set", json!({ "doc": doc, "pages": [2, 3], "style": "Wipe", "duration": 2, "direction": 270, "display": 5 })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t1.pdf" })); + let t = entry(&dir, "out/t1.pdf", 1, b"Trans").expect("/Trans on page 2"); + let t = t.as_dict().expect("a dictionary"); + assert_eq!( + (t.name(b"S"), t.get(b"D").and_then(Object::as_f64), t.get(b"Di").and_then(Object::as_f64)), + (Some(&b"Wipe"[..]), Some(2.0), Some(270.0)), + "{t:?}" + ); + assert_eq!(entry(&dir, "out/t1.pdf", 1, b"Dur").and_then(|o| o.as_f64()), Some(5.0)); + assert!(entry(&dir, "out/t1.pdf", 2, b"Trans").is_some(), "page 3 too"); + assert!(entry(&dir, "out/t1.pdf", 0, b"Trans").is_none() && entry(&dir, "out/t1.pdf", 0, b"Dur").is_none(), "page 1 was not asked for"); + ok( + &mut a, + "page_transition_set", + json!({ "doc": doc, "pages": [3], "style": "Fly", "motion": "outward", "direction": "none", "scale": 0.5, "opaque": true }), + ); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t2.pdf" })); + let t = entry(&dir, "out/t2.pdf", 2, b"Trans").expect("/Trans on page 3"); + let t = t.as_dict().expect("a dictionary"); + assert_eq!( + (t.name(b"S"), t.name(b"M"), t.name(b"Di"), t.get(b"SS").and_then(Object::as_f64), t.get(b"B")), + (Some(&b"Fly"[..]), Some(&b"O"[..]), Some(&b"None"[..]), Some(0.5), Some(&Object::Bool(true))), + "{t:?}" + ); + assert!(entry(&dir, "out/t2.pdf", 2, b"Dur").is_none(), "display time left out removes /Dur"); + assert!(a.call("page_transition_set", &json!({ "doc": doc, "style": "Swirl" })).is_err(), "an unknown style is refused"); + assert!(a.call("page_transition_set", &json!({ "doc": doc, "style": "Wipe", "direction": 45 })).is_err()); + assert!(a.call("page_transition_set", &json!({ "doc": doc, "pages": [9], "style": "Fade" })).is_err(), "no such page"); + ok(&mut a, "page_transition_set", json!({ "doc": doc, "style": "none" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t3.pdf" })); + assert!((0..3).all(|p| entry(&dir, "out/t3.pdf", p, b"Trans").is_none() && entry(&dir, "out/t3.pdf", p, b"Dur").is_none()), "all removed"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t4.pdf" })); + assert!( + entry(&dir, "out/t4.pdf", 1, b"Trans").is_some() && entry(&dir, "out/t4.pdf", 2, b"Trans").is_some(), + "one undo step brings both pages back" + ); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); @@ -145,6 +244,30 @@ fn autotag_preview_proposes_text_without_changing_the_pdf() { assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P")); } +#[test] +fn render_pixels_agrees_with_page_render_and_skips_the_png() { + let dir = workdir("render-pixels"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + + let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { width: png_width, height: png_height, .. } = png[0] else { panic!("expected an image") }; + assert_eq!((width, height), (png_width, png_height), "scale 1.0 is 72 dpi, like the tool"); + assert_eq!(pixels.as_ref().len(), width as usize * height as usize * 4, "RGBA, four bytes a pixel"); + assert!(pixels.as_ref().as_chunks::<4>().0.iter().any(|p| p[..3] != [255, 255, 255]), "the page text is drawn, not a blank sheet"); + + let (double_width, _, _) = a.render_pixels(doc, 1, 2.0).unwrap(); + assert_eq!(double_width, width * 2, "the scale is pixels per point"); + + for scale in [0.0_f32, -1.0, f32::NAN, f32::INFINITY, 1.0e6] { + assert!(matches!(a.render_pixels(doc, 1, scale), Err(ToolError::InvalidArgs(_))), "scale {scale} must be refused"); + } + assert!(matches!(a.render_pixels(doc, 0, 1.0), Err(ToolError::InvalidArgs(_))), "pages are 1-based"); + assert!(matches!(a.render_pixels(doc, 99, 1.0), Err(ToolError::InvalidArgs(_))), "a page past the end"); + assert!(matches!(a.render_pixels(9_999, 1, 1.0), Err(ToolError::Failed(_))), "a document that is not open"); +} + #[test] fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() { let dir = workdir("autotag-apply"); @@ -238,6 +361,9 @@ fn tool_table_is_well_formed() { assert!(props.contains_key(r.as_str().unwrap()), "{}: required {r} is not a property", t.name); } assert!(!(t.read_only && t.destructive), "{} is both read-only and destructive", t.name); + for k in ["oneOf", "anyOf", "allOf"] { + assert!(t.input_schema.get(k).is_none(), "{}: top-level {k} in input_schema is rejected by MCP clients such as Claude", t.name); + } if let Some(c) = t.command { assert!(pdfcraft_engine::commands::command(c).is_some(), "{} names unregistered command {c}", t.name); } @@ -1439,6 +1565,54 @@ fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() { assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_)))); } +/// Bookmarks and links say where on the page they go, in the same top-left points as rect, and a +/// link through a named destination says which page that is. +#[test] +fn destinations_are_listed_with_their_position() { + let dir = workdir("destination-positions"); + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec-b) [4 0 R /XYZ 72 500 0]] >> >> >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>", + "<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 6 0 R /Last 7 0 R /Count 2 >>", + "<< /Title (Section B) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 72 500 0] >>", + "<< /Title (Wide view) /Parent 5 0 R /Prev 6 0 R /Dest [4 0 R /FitH 400] >>", + "<< /Type /Annot /Subtype /Link /Rect [72 700 300 716] /Border [0 0 0] /Dest [4 0 R /XYZ 72 500 2] >>", + "<< /Type /Annot /Subtype /Link /Rect [72 670 300 686] /Border [0 0 0] /Dest (sec-b) >>", + "<< /Type /Annot /Subtype /Link /Rect [72 640 300 656] /Border [0 0 0] /A << /S /URI /URI (https://example.com) >> >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("dests.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "dests.pdf" }))["doc"].as_u64().unwrap(); + let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc })); + // 792 − 500 = 292 points from the top; a zoom of 0 keeps the reader's zoom. + assert_eq!(marks["bookmarks"][0]["destination"], json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": null }), "{marks}"); + assert_eq!(marks["bookmarks"][1]["destination"], json!({ "fit": "FitH", "left": null, "top": 392.0 }), "{marks}"); + let links = ok(&mut a, "link_list", json!({ "doc": doc })); + let explicit = &links["links"][0]; + assert_eq!( + (explicit["to_page"].clone(), explicit["destination"].clone()), + (json!(2), json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": 2.0 })) + ); + let named = &links["links"][1]; + assert_eq!((named["to_page"].clone(), named["named"].clone(), named.get("other")), (json!(2), json!(true), None), "{links}"); + assert_eq!(named["destination"]["top"], 292.0); + assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None)); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); @@ -1656,7 +1830,7 @@ fn protecting_through_tools() { fn signing_an_encrypted_form_through_tools() { let dir = workdir("sign-encrypted"); let mut a = auto(&dir); - ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); let field = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "signature", "rect": [20, 200, 180, 250] }))["field"] .as_str() @@ -1669,12 +1843,12 @@ fn signing_an_encrypted_form_through_tools() { assert_eq!(ok(&mut b, "doc_info", json!({ "doc": form }))["security"]["protected"], true); match b.call( "sign_document", - &json!({ "doc": form, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }), + &json!({ "doc": form, "id": "ada.p12", "password": "secret12", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }), ) { Err(ToolError::Failed(m)) => assert!(m.contains("adding new signature fields"), "{m}"), other => panic!("a new field needs the permissions password: {other:?}"), } - let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret1", "field": field, "out": "signed.pdf" })); + let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret12", "field": field, "out": "signed.pdf" })); assert_eq!((r["signature"]["signer"].as_str(), r["signature"]["status"].as_str()), (Some("Ada Lovelace"), Some("unknown"))); let original = std::fs::read(dir.join("form.pdf")).unwrap(); let signed = std::fs::read(dir.join("signed.pdf")).unwrap(); @@ -1682,7 +1856,7 @@ fn signing_an_encrypted_form_through_tools() { let mut c = auto(&dir); let re = ok(&mut c, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut c, "doc_info", json!({ "doc": re }))["security"]["protected"], true, "still encrypted"); - ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" })); + ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); let list = ok(&mut c, "sign_list", json!({ "doc": re })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")), "{list}"); } @@ -2323,6 +2497,12 @@ fn editing_page_images_through_tools() { assert_eq!(r["undo"], "Move image"); let moved = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0])); + // Crop keeps part of it in place: the placement stays, visible_rect says what shows. + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "crop", "rect": [10, 10, 25, 30] })); + assert_eq!(r["undo"], "Crop image"); + let cropped = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0].clone(); + assert_eq!(cropped["rect"], json!([5.0, 5.0, 25.0, 35.0])); + assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); @@ -2923,6 +3103,28 @@ fn flattening_through_tools() { assert!(matches!(a.call("doc_flatten", &json!({ "doc": doc, "comments": false, "fields": false })), Err(ToolError::InvalidArgs(_)))); } +/// Inserting a file's pages brings its bookmarks too, under one named after the file, at the +/// pages' place among the document's own bookmarks. +#[test] +fn inserting_a_file_brings_its_bookmarks() { + let dir = workdir("insert-bookmarks"); + let mut a = auto(&dir); + let annex = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": annex, "title": "Annex intro", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": annex, "title": "Annex table", "page": 2 })); + ok(&mut a, "doc_save", json!({ "doc": annex, "path": "annex.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Guide start", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Guide end", "page": 3 })); + ok(&mut a, "page_insert_file", json!({ "doc": doc, "path": "annex.pdf", "at": 2 })); + let list = ok(&mut a, "bookmark_list", json!({ "doc": doc })); + let top: Vec<(&str, Value)> = list["bookmarks"].as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap(), b["page"].clone())).collect(); + assert_eq!(top, [("Guide start", json!(1)), ("annex", json!(2)), ("Guide end", json!(5))], "{list}"); + let kids: Vec<(&str, Value)> = + list["bookmarks"][1]["children"].as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap(), b["page"].clone())).collect(); + assert_eq!(kids, [("Annex intro", json!(2)), ("Annex table", json!(3))], "{list}"); +} + #[test] fn replacing_pages_through_tools() { let dir = workdir("replace"); @@ -3208,6 +3410,67 @@ fn an_applied_redaction_at_the_end_of_a_line_audits_clean() { assert_eq!(r["count"], 0, "{r}"); } +/// A long tagged document lists its tags a page at a time, and only the types, pages or depths +/// asked for: a 20-page guide has hundreds of tags, more than one tool result should hold. +#[test] +fn structure_tags_are_listed_a_page_at_a_time() { + let dir = workdir("tags-paging"); + // A Document holding, on each of 3 pages, one H1 and 149 P: 451 tags. + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>".into(), + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 200 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + String::new(), // 6: the root, below + String::new(), // 7: the Document, below + ]; + let mut kids = Vec::new(); + for page in 0..3 { + for i in 0..150 { + let n = objs.len() + 1; + kids.push(format!("{n} 0 R")); + objs.push(format!("<< /S /{} /P 7 0 R /Pg {} 0 R >>", if i == 0 { "H1" } else { "P" }, 3 + page)); + } + } + objs[5] = "<< /Type /StructTreeRoot /K 7 0 R >>".into(); + objs[6] = format!("<< /S /Document /P 6 0 R /K [{}] >>", kids.join(" ")); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("long.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap(); + let first = ok(&mut a, "accessibility_tags", json!({ "doc": doc })); + assert_eq!((first["count"].as_u64(), first["tags"].as_array().map(Vec::len), first["next"].as_u64()), (Some(451), Some(200), Some(200))); + assert_eq!(first["tags"][0]["type"], "Document"); + let last = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "offset": 400, "limit": 100 })); + assert_eq!((last["tags"].as_array().map(Vec::len), last["next"].clone()), (Some(51), Value::Null)); + let headings = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "types": ["H1"] })); + let pages: Vec = headings["tags"].as_array().unwrap().iter().map(|t| t["page"].as_u64().unwrap()).collect(); + assert_eq!((headings["count"].as_u64(), pages), (Some(3), vec![1, 2, 3])); + let page2 = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "pages": [2], "types": ["H1", "P"], "limit": 1000 })); + assert_eq!((page2["count"].as_u64(), page2["next"].clone()), (Some(150), Value::Null)); + assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc, "max_depth": 0 }))["count"], 1, "the Document alone"); + for bad in [ + json!({ "doc": doc, "limit": 0 }), + json!({ "doc": doc, "limit": 1001 }), + json!({ "doc": doc, "offset": -1 }), + json!({ "doc": doc, "pages": [0] }), + ] { + assert!(matches!(a.call("accessibility_tags", &bad), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn printing_through_tools() { let dir = workdir("print"); @@ -3927,6 +4190,33 @@ fn text_the_standard_fonts_cannot_draw_is_never_silent_through_tools() { assert!(!text.contains("?? OK"), "{text}"); } +/// #1908: `comment_edit font_size` resizes the text of a text box, in one undo step, and refuses +/// sizes outside 1-400 pt and comments without text. +#[test] +fn text_box_font_size_through_the_tool() { + let dir = workdir("fontsize"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [20, 40, 200, 80], "contents": "Hello" })); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [20, 120, 80, 160] })); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "font_size": 30 })); + for bad in [0, 401] { + assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "font_size": bad })), Err(ToolError::InvalidArgs(_)))); + } + assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 2, "font_size": 12 })).is_err()); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "sized.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("sized.pdf")).unwrap())).unwrap(); + let sizes: Vec = saved + .object_numbers() + .iter() + .filter_map(|n| saved.try_get(*n).ok()) + .filter_map(|o| o.as_dict().and_then(|d| d.get(b"DA")?.as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned()))) + .collect(); + assert_eq!(sizes.len(), 1, "{sizes:?}"); + assert!(sizes[0].ends_with("/Helv 30 Tf"), "{sizes:?}"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties"); +} + #[test] fn line_endings_through_the_tool() { let dir = workdir("endings"); @@ -4052,12 +4342,12 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { std::fs::write(dir.join("logo.png"), png).unwrap(); std::fs::write(dir.join("broken.png"), b"broken").unwrap(); let mut a = auto(&dir); - ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| { a.call( "sign_document", - &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }), + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 1, "rect": rect, "graphic": graphic, "out": out }), ) }; for (graphic, why) in [ @@ -4077,7 +4367,7 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { // A missing file fails as it does for every tool that reads one. assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_)))); // An invisible signature shows nothing, so a graphic for one is a mistake. - let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" })); + let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "graphic": { "text": "Ada" }, "out": "bad.pdf" })); assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}"); assert!(!dir.join("bad.pdf").exists()); @@ -4113,11 +4403,13 @@ fn digital_ids_signing_and_validation_through_tools() { let id = ok( &mut a, "sign_id_create", - json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }), + json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, E=ada@example.com"); assert_eq!(id["certificate"]["self_signed"], true); assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); + // Eight characters at least (#1847): seven is refused. + assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); @@ -4140,7 +4432,7 @@ fn digital_ids_signing_and_validation_through_tools() { let r = ok( &mut a, "sign_document", - json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), + json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); assert_eq!(r["signature"]["status"], "unknown"); assert_eq!(r["signature"]["signer"], "Ada Lovelace"); @@ -4150,7 +4442,7 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" })); + let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); let list = ok(&mut a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); @@ -4172,6 +4464,89 @@ fn digital_ids_signing_and_validation_through_tools() { assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); } +/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. +fn signature_appearances(path: &Path) -> String { + use pdfcraft_cos::{Document, ObjRef, Object}; + let doc = Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + let mut text = String::new(); + for num in doc.object_numbers() { + if let Object::Stream(s) = &*doc.get(ObjRef { num, generation: 0 }) + && s.dict.name(b"Subtype") == Some(b"Form") + { + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + if content.contains("/Helv") { + text.push_str(&content); + } + } + } + text +} + +/// `sign_document` takes the words of a visible signature's labels (say, in German) and which +/// items it shows, as the interface does. A label the signature's font can't draw is refused, +/// naming the label, the character and what is allowed, before anything is written. +#[test] +fn signing_with_translated_labels_through_tools() { + let dir = workdir("signing-labels"); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" })); + let open = |a: &mut Automation| ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let doc = open(&mut a); + let args = |doc: u64, extra: Value, out: &str| { + let mut args = json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [10, 150, 190, 290], "reason": "Genehmigt", "location": "Berlin", "out": out }); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + args + }; + for (extra, why, says) in [ + ( + json!({ "labels": { "signed_by": "数字签名者" } }), + "a label Helvetica can't draw", + "labels.signed_by: the standard fonts can't draw \"数\" (U+6570)", + ), + (json!({ "labels": { "reason": "Причина: " } }), "even one this signature doesn't show", "labels.reason"), + (json!({ "labels": { "signer": "Von" } }), "an unknown label", "unknown label \"signer\""), + (json!({ "labels": { "date": 1 } }), "not a string", "labels.date must be a string"), + (json!({ "labels": "Datum" }), "not an object", "labels must be an object"), + (json!({ "labels": { "date": "x".repeat(201) } }), "too long", "longer than 200"), + (json!({ "appearance": { "reason": "yes" } }), "not true or false", "appearance.reason must be true or false"), + (json!({ "appearance": { "logo": true } }), "an unknown item", "unknown appearance item"), + (json!({ "rect": null, "labels": { "date": "Datum: " } }), "labels for an invisible signature", "needs a visible signature"), + (json!({ "rect": null, "appearance": { "reason": true } }), "an appearance for an invisible one", "needs a visible signature"), + ] { + let r = a.call("sign_document", &args(doc, extra, "bad.pdf")); + assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains(says)), "{why}: {r:?}"); + } + let r = a.call("sign_document", &args(doc, json!({ "labels": { "signed_by": "数字签名者" } }), "bad.pdf")); + assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("signed_by label") && m.contains("Western European")), "{r:?}"); + assert!(!dir.join("bad.pdf").exists(), "nothing is written for a refused call"); + + // German labels, with the reason and the location shown: stamped as given. + let german = json!({ + "labels": { "signed_by": "Digital signiert von", "reason": "Grund: ", "location": "Ort: ", "date": "Datum: " }, + "appearance": { "reason": true, "location": true }, + }); + let r = ok(&mut a, "sign_document", args(doc, german, "de.pdf")); + assert_eq!(r["signature"]["reason"], "Genehmigt"); + let stamped = signature_appearances(&dir.join("de.pdf")); + // Each line starts with its label (a long one wraps after it). + for word in ["(Digital", "(Grund:", "Genehmigt", "(Ort:", "Berlin", "(Datum:"] { + assert!(stamped.contains(word), "{word}: {stamped}"); + } + assert!(!stamped.contains("Reason") && !stamped.contains("Date:"), "{stamped}"); + + // Labels omitted keep the English; the labels turned off show the values alone. + let doc = open(&mut a); + ok(&mut a, "sign_document", args(doc, json!({ "labels": { "date": "Datum: " }, "appearance": { "reason": true } }), "mixed.pdf")); + let stamped = signature_appearances(&dir.join("mixed.pdf")); + assert!(stamped.contains("(Reason:") && stamped.contains("(Datum:") && !stamped.contains("Berlin"), "{stamped}"); + let doc = open(&mut a); + ok(&mut a, "sign_document", args(doc, json!({ "appearance": { "reason": true, "labels": false } }), "bare.pdf")); + let stamped = signature_appearances(&dir.join("bare.pdf")); + assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}"); +} + #[test] fn optimizing_through_tools() { let dir = workdir("optimize"); @@ -4729,6 +5104,33 @@ fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() { eprintln!("{} calculate scripts: open {open_took:?}, recalculation after a change {change_took:?}", sections * rows + sections); } +#[test] +fn xfa_forms_flatten_and_convert_to_static_through_tools() { + let dir = workdir("xfa-flat"); + let data = "
JP
"; + std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap(); + let mut a = auto(&dir); + // Convert to static: the fields stay, the XFA goes. + let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"], "dynamic"); + let before = ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(); + ok(&mut a, "xfa_to_static", json!({ "doc": doc })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info["xfa"].is_null(), "{info}"); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), before); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "static.pdf" })); + let again = ok(&mut a, "doc_open", json!({ "path": "static.pdf" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut a, "doc_info", json!({ "doc": again }))["xfa"].is_null()); + assert!(a.call("xfa_to_static", &json!({ "doc": again })).is_err(), "not an XFA form"); + // Flatten the fields: a plain PDF with the values as page content. + let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_flatten", json!({ "doc": doc, "comments": false, "fields": true })); + assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"].is_null()); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), 0); + let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] })); + assert!(text.to_string().contains("Japan"), "{text}"); +} + #[test] fn xfa_data_files_export_and_import_through_tools() { let dir = workdir("xfa-data"); @@ -5393,6 +5795,33 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() { assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ")); } +#[test] +fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() { + let dir = workdir("hebrew-content"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)"; + let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"}); + if pdfcraft_fonts::document_hebrew_font(false).is_none() { + assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9")); + assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0); + return; + } + ok(&mut a, "page_add_text", args); + // Extraction gives back the typing order, not the drawing order. + assert!(page_text(&mut a, doc)[0].contains(text), "{:?}", page_text(&mut a, doc)); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "hebrew.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path": "hebrew.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "content_list", json!({"doc": reopened}))["items"][0]["text"], text); + assert!(page_text(&mut a, reopened)[0].contains(text)); + let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "שרון"})); + assert_eq!(found["matches"].as_array().map(Vec::len), Some(1), "{found}"); + let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 144})).unwrap(); + assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, width, height } if !data.is_empty() && *width > 0 && *height > 0))); + ok(&mut a, "content_update", json!({"doc": reopened, "page": 1, "index": 1, "text": "מיכאל שָׁלוֹם"})); + assert!(page_text(&mut a, reopened)[0].contains("מיכאל")); +} + #[test] fn mixed_objects_move_through_tools_with_generation_and_one_undo() { let dir = workdir("mixed-object-move"); @@ -5962,3 +6391,41 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() { ok(&mut a, "doc_open", json!({ "path": "inside.pdf" })); let _ = std::fs::remove_dir_all(base); } + +/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside +/// 72–600 is refused. +#[test] +fn doc_print_as_image() { + let dir = workdir("print-image"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "as_image": true, "dpi": 150, "grayscale": true, "path": "image.pdf" })); + assert_eq!(r["sheets"], 1); + let bytes = std::fs::read(dir.join("image.pdf")).unwrap(); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let pictures = + out.scan_objects().filter(|(_, o)| matches!(&**o, pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image"))).count(); + assert_eq!(pictures, 1); + match a.call("doc_print", &json!({ "doc": doc, "as_image": true, "dpi": 20, "path": "x.pdf" })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("dpi"), "{m}"), + other => panic!("{other:?}"), + } +} + +/// Without a root, `write_output` still writes through a staging file (#2166): the folder is +/// made, nothing is left beside the file, and a folder at the path is refused. +#[test] +fn a_root_less_write_output_is_atomic_too() { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-rootless-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let free = Automation::new(); + let target = dir.join("new").join("page.png"); + let written = free.write_output(&target.to_string_lossy(), &[1, 2, 3]).unwrap(); + assert_eq!(written, target); + assert_eq!(std::fs::read(&target).unwrap(), [1, 2, 3]); + let names: Vec<_> = std::fs::read_dir(dir.join("new")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names, ["page.png"], "no staging file left beside it"); + let e = free.write_output(&dir.join("new").to_string_lossy(), &[4]).unwrap_err(); + assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 1f197218ab..5cd68b9180 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -441,6 +441,13 @@ impl std::fmt::Debug for Document { } } +/// How an encrypted document is opened: with a password, or with the file key of a document +/// that has the same encryption (an earlier revision of the same file). +enum Credentials<'a> { + Password(Option<&'a str>), + Handler { handler: &'a Arc, id0: Vec }, +} + impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no /// original bytes, so saving always writes a full file. @@ -497,6 +504,23 @@ impl Document { /// document is read-only: objects in a larger object stream read as missing, so the /// writers refuse to save it ([`CosError::ReadOnlyLimit`]). pub fn open_with_stream_limit(data: Arc>, password: Option<&str>, stream_limit: usize) -> Result { + Self::open_as(data, Credentials::Password(password), stream_limit) + } + + /// Open `data`, an earlier revision of this file (the bytes a signature covers) or another + /// save of it, with this document's credentials: an encrypted one must have the same + /// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no + /// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data + /// encrypted differently fails with `NeedsPassword`. + pub fn open_with_same_credentials(&self, data: Arc>) -> Result { + let credentials = match &self.security { + Some(handler) => Credentials::Handler { handler, id0: self.first_id() }, + None => Credentials::Password(None), + }; + Self::open_as(data, credentials, self.stream_limit) + } + + fn open_as(data: Arc>, credentials: Credentials<'_>, stream_limit: usize) -> Result { // Viewers accept files whose header is missing or damaged as long as the body looks // like PDF; so do we (a note goes to the repair log). let header = find(&data, b"%PDF-", 0, 1024); @@ -546,7 +570,7 @@ impl Document { // Authenticate before checking the catalog: it may sit in an encrypted object // stream, which reads as garbage until the key is known. if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, password)?; + doc.authenticate(&enc, &credentials)?; authenticated = true; } doc.root_is_catalog() @@ -567,7 +591,7 @@ impl Document { if (!ok || !authenticated) && let Some(enc) = doc.trailer.get(b"Encrypt").cloned() { - doc.authenticate(&enc, password)?; + doc.authenticate(&enc, &credentials)?; } let max = doc.entries.max_num(); let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32; @@ -575,7 +599,7 @@ impl Document { Ok(doc) } - fn authenticate(&mut self, enc: &Object, password: Option<&str>) -> Result<(), CosError> { + fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> { // Authentication can change how the same input is decoded. self.source_identity = Arc::new(()); let (num, dict) = match enc { @@ -593,12 +617,17 @@ impl Document { }; let params = crate::security::encrypt_dict(self, &dict); let id0 = self.first_id(); - let handler = pdfcraft_crypt::SecurityHandler::open(params, &id0, password).map_err(|e| match e { - pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, - pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, - other => CosError::Security(other.to_string()), - })?; - self.security = Some(Arc::new(handler)); + let handler = match credentials { + Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e { + pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword, + pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword, + other => CosError::Security(other.to_string()), + })?), + // The same encryption as the document whose key this is: that key opens it. + Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler), + Credentials::Handler { .. } => return Err(CosError::NeedsPassword), + }; + self.security = Some(handler); self.encrypt_num = num; // Objects read while locating the catalog were read before decryption was possible. self.cache = Arc::default(); diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 34254bf374..f8d8fd3d80 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -4,7 +4,8 @@ //! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are //! never modified, which keeps digital signatures over earlier revisions valid. //! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a -//! cross-reference table or stream — the "Save As" / garbage-collecting path. +//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer +//! entries are copied, including objects reachable only from them. use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, VecDeque}; @@ -257,6 +258,13 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro queue.push_back(r); } } + // Unknown trailer entries are document data. Objects they alone reference must be written + // too, or a full rewrite would keep the key and drop its value. + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + collect_refs(value, &mut |r| queue.push_back(r)); + } + } while let Some(r) = queue.pop_front() { if map.contains_key(&r) { continue; @@ -337,6 +345,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro if let Some(id) = trailer_in.get(b"ID") { trailer.set(b"ID".to_vec(), id.clone()); } + for (key, value) in trailer_in.iter() { + if !trailer_key_rewritten(key) { + trailer.set(key.clone(), renumber(value, &map)); + } + } if reader.document.output_security().0.is_none() { ensure_id(&mut trailer, opts, reader.document.bytes()); } @@ -400,6 +413,15 @@ impl ObjectStreamBatch { } } +/// Trailer keys a full rewrite generates itself. Everything else is copied, with references +/// renumbered, so a save does not drop metadata the reader accepted. +fn trailer_key_rewritten(key: &[u8]) -> bool { + matches!( + key, + b"Size" | b"Prev" | b"XRefStm" | b"Type" | b"W" | b"Index" | b"Filter" | b"DecodeParms" | b"Length" | b"Root" | b"Info" | b"Encrypt" | b"ID" + ) +} + fn output_number(index: usize) -> Result { u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() }) } diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs index 849dbdc840..af92cf67c7 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/encryption.rs @@ -301,3 +301,38 @@ fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() { assert_eq!(content(&again), SECRET, "/Length {length}"); } } + +#[test] +fn an_earlier_revision_opens_with_the_documents_own_credentials() { + // The bytes a signature covers are an earlier revision of the file: the document that is + // open can read them with its own key, so no password has to be kept or asked for again. + for alg in ALL { + let original = protect(alg, "user", "owner", -1); + let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap(); + let info = doc.trailer().reference(b"Info").unwrap(); + doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised")))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let current = Document::open_with_password(Arc::new(saved), Some("user")).unwrap(); + assert!(matches!(Document::open(Arc::new(original.clone())), Err(CosError::NeedsPassword)), "{alg:?}: not without a password"); + let earlier = current.open_with_same_credentials(Arc::new(original.clone())).unwrap(); + assert_eq!(content(&earlier), SECRET, "{alg:?}: the earlier revision is readable"); + assert_eq!(title(&earlier).as_deref(), Some("Quarterly numbers"), "{alg:?}: and it is the earlier one"); + // Another file, encrypted with other keys: the document's key is no use, and it says so. + let other = { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: alg, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [3; 32], + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() + }; + assert!(matches!(current.open_with_same_credentials(Arc::new(other)), Err(CosError::NeedsPassword)), "{alg:?}"); + // An unencrypted file opens as it always does. + assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); + } +} diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index a7f071e8e7..ec18b13f36 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -327,3 +327,68 @@ fn page_tree_loops_in_object_streams_do_not_crash_readers() { assert!(String::from_utf8_lossy(&packed).contains("/ObjStm")); assert_eq!(hayro_pages(&packed), 1); } + +/// Catalog, empty page tree, and trailer entries a reader must not invent: a direct name, a +/// direct string, and a string object reachable only from the trailer. +fn trailer_fixture() -> Vec { + let mut out = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(only from the trailer)"].iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(b"xref\n0 4\n0000000000 65535 f \n"); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!("trailer\n<< /Size 4 /Root 1 0 R /Version /1.7 /Custom (keep me) /Note 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(), + ); + out +} + +fn trailer_custom(doc: &Document) -> Option> { + doc.trailer().get(b"Custom").and_then(Object::as_string).map(|s| s.bytes.clone()) +} + +fn trailer_note(doc: &Document) -> Option> { + doc.trailer().get(b"Note").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone())) +} + +#[test] +fn full_save_preserves_unknown_trailer_data() { + let input = trailer_fixture(); + let doc = Document::open(Arc::new(input)).unwrap(); + assert_eq!(trailer_custom(&doc), Some(b"keep me".to_vec())); + assert_eq!(trailer_note(&doc).as_deref(), Some(b"only from the trailer".as_slice())); + assert_eq!(doc.trailer().get(b"Version").and_then(Object::as_name), Some(&b"1.7"[..])); + assert!(doc.repair_log().is_empty()); + assert!(!doc.full_save_required()); + + let inc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(trailer_custom(&inc), trailer_custom(&doc)); + assert_eq!(trailer_note(&inc), trailer_note(&doc)); + assert_eq!(inc.trailer().get(b"Version"), doc.trailer().get(b"Version")); + assert!(inc.repair_log().is_empty()); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let bytes = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(bytes)).unwrap(); + assert!(back.root().is_some()); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(trailer_custom(&back), trailer_custom(&doc)); + assert_eq!(trailer_note(&back), trailer_note(&doc)); + assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); + } + + let mut required = doc.clone(); + required.require_full_save(); + let back = Document::open(Arc::new(write_incremental(&required, &SaveOptions::default()).unwrap())).unwrap(); + assert!(back.root().is_some()); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(trailer_custom(&back), trailer_custom(&doc)); + assert_eq!(trailer_note(&back), trailer_note(&doc)); + assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); +} diff --git a/crates/crypt/tests/proptests.rs b/crates/crypt/tests/proptests.rs new file mode 100644 index 0000000000..e4f1d78b57 --- /dev/null +++ b/crates/crypt/tests/proptests.rs @@ -0,0 +1,464 @@ +//! Property tests for what the security handler does to *content*, and to the ciphertext a +//! stranger's file hands back. +//! +//! `src/tests.rs` covers this handler carefully with fixed vectors — including the `/O`, `/U` and +//! file keys qpdf 12.4.2 produced for real files — plus one property over malformed dictionaries. +//! What it does not vary is the *payload*. Upstream's encrypted round trips run three shapes: a +//! 30-byte text, a 1000-byte ramp, and a 10-byte attachment in `identity_crypt_filters_leave_data_alone`. +//! The shapes nobody walks are the ones that break CBC and readers: empty content, one block minus +//! one, a ciphertext cut short by a damaged `/Length`, and keys of a length `create` never builds. +//! +//! - plaintext survives encryption at every length, for each algorithm and each crypt filter; +//! - truncated or mutated ciphertext produces bytes rather than a panic (`AGENTS.md` §4: PdfCraft +//! opens files from strangers, and encrypted streams in the wild do get truncated); +//! - object keys are scoped to their object, and AES-256's documented exception still holds; +//! - a file opens for exactly the passwords whose effective bytes match, stated as a rule over +//! generated Unicode passwords instead of the three examples in `src/tests.rs` (the #1185 rule); +//! - the AES-CBC and RC4 primitives invert each other for any key length, since `/Length` and +//! crypt filters from a damaged file reach them with shapes `create` would never build. + +use pdfcraft_crypt::{ + Algorithm, Auth, CryptError, Method, NewEncryption, PADDING, SecurityHandler, StreamKind, aes_cbc_decrypt, aes_cbc_encrypt, create, + effective_password, pdfdoc_byte, rc4, +}; +use proptest::prelude::*; + +/// The first element of `/ID`, which every R2–R4 derivation hashes in (Algorithm 2). +const ID: &[u8] = b"\x01\x23\x45\x67\x89\xab\xcd\xef\x0f\xed\xcb\xa9\x87\x65\x43\x21"; + +fn algorithms() -> impl Strategy { + prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256]) +} + +/// Content at the lengths where CBC padding goes wrong, plus anything in between. +fn payloads() -> impl Strategy> { + prop_oneof![ + Just(Vec::new()), + Just(vec![0x5cu8; 1]), + Just(vec![0xa7u8; 15]), + Just(vec![0x3fu8; 16]), + Just(vec![0x9du8; 17]), + Just(vec![0x10u8; 32]), + prop::collection::vec(any::(), 1..96), + ] +} + +/// Passwords from an alphabet the two encodings disagree about: SASLprep deletes U+00AD, folds +/// U+FF21 to "A", U+FB01 to "fi" and U+3000 to a space (revisions 5–6), while PDFDocEncoding has +/// no byte for 'ğ' or CJK at all, which sends revisions 2–4 down their UTF-8 fallback. +const PASSWORD_CHARS: [char; 12] = ['a', 'B', '7', ' ', '-', '\u{AD}', '\u{FF21}', '\u{FB01}', 'ı', 'ğ', '日', '\u{A0}']; + +fn passwords() -> impl Strategy { + prop::collection::vec(prop::sample::select(PASSWORD_CHARS.to_vec()), 0..12).prop_map(|chars| chars.into_iter().collect()) +} + +/// `bytes` cut to 32 and padded with the standard padding string (Algorithm 2, step a). +fn padded(bytes: &[u8]) -> Vec { + let mut out = PADDING; + let n = bytes.len().min(32); + out[..n].copy_from_slice(&bytes[..n]); + out.to_vec() +} + +/// Every byte string the handler may authenticate `pw` as. Revisions 2–4 use PDFDocEncoding, and +/// for a password holding characters it lacks they also try the raw UTF-8 bytes (qpdf writes such +/// passwords that way); revisions 5–6 use the SASLprep bytes. `effective_password` publishes the +/// first of these per algorithm, and it is what callers such as Protect check before encrypting. +fn candidates(algorithm: Algorithm, pw: &str) -> Vec> { + let mut out = vec![effective_password(algorithm, pw)]; + if algorithm != Algorithm::Aes256 && pw.chars().any(|c| pdfdoc_byte(c).is_none()) { + out.push(padded(&pw.as_bytes().iter().copied().take(32).collect::>())); + } + out +} + +/// What `create` keys `/O` from: the owner password, except on revisions 2–4 with no owner +/// password given, where it falls back to the user password's bytes. +fn owner_target(algorithm: Algorithm, user: &str, owner: &str) -> Vec { + if algorithm != Algorithm::Aes256 && owner.is_empty() { effective_password(algorithm, user) } else { effective_password(algorithm, owner) } +} + +/// Which password `pw` is for this file. Owners are tried before users, so bytes that match both +/// authenticate as owner — the order viewers use. `None` means it opens nothing. +fn expected_auth(algorithm: Algorithm, user_target: &[u8], owner_target: &[u8], pw: &str) -> Option { + let tried = candidates(algorithm, pw); + if tried.iter().any(|c| c == owner_target) { + Some(Auth::Owner) + } else if tried.iter().any(|c| c == user_target) { + Some(Auth::User) + } else { + None + } +} + +fn make(algorithm: Algorithm, user: &str, owner: &str) -> SecurityHandler { + create(&NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [0x5e; 32] }, ID) + .unwrap() +} + +proptest! { + /// Encrypting then decrypting returns the input at every length, as a string and as each kind + /// of stream — and is byte-for-byte reproducible, because re-running a save must not silently + /// produce different bytes. Payloads of at least one block must come back changed: RC4 would + /// need an all-zero keystream to leave them alone, and CBC cannot shrink a padded block. + #[test] + fn content_survives_the_trip_at_every_length(alg in algorithms(), data in payloads()) { + let h = make(alg, "user", "owner"); + + // The key width each algorithm promises: 5 bytes for export-grade 40-bit RC4, 16 for + // AES-128 and RC4-128, 32 for AES-256. `key_len` is the only place that decides this and + // nothing in either suite reads it back, so `if d.r == 2 { 5 }` becoming `{ 4 }` today + // produces a file key of the wrong size that every test still calls fine. + let want_len = match alg { Algorithm::Rc4_40 => 5, Algorithm::Aes128 | Algorithm::Rc4_128 => 16, Algorithm::Aes256 => 32 }; + prop_assert_eq!(h.file_key().len(), want_len, "{:?} must use a {} byte file key", alg, want_len); + + let h = make(alg, "user", "owner"); + for num in [0u32, 1, 4096, u32::MAX] { + let generation = (num % 65_535) as u16; + + let enc = h.encrypt_string(num, generation, &data); + prop_assert_eq!(h.decrypt_string(num, generation, &enc), &data[..], "string round trip at {} bytes ({:?})", data.len(), alg); + prop_assert_eq!(h.encrypt_string(num, generation, &data), enc, "string encryption must be reproducible ({:?})", alg); + + for kind in [StreamKind::Normal, StreamKind::EmbeddedFile, StreamKind::Named(b"StdCF")] { + let enc = h.encrypt_stream(num, generation, &data, kind); + prop_assert_eq!(h.decrypt_stream(num, generation, &enc, kind), &data[..], "stream round trip at {} bytes ({:?}, {:?})", data.len(), alg, kind); + prop_assert_eq!(h.encrypt_stream(num, generation, &data, kind), enc, "{:?} {:?} encryption must be reproducible", alg, kind); + } + + if data.len() >= 16 { + prop_assert_ne!(h.encrypt_string(num, generation, &data), &data[..], "one block left in plain sight ({:?})", alg); + } + } + } + + /// Ciphertext of any length — truncated by a damaged `/Length`, padded out with garbage, or + /// byte-flipped inside — must decrypt to bytes and never read past the end. AES discards a + /// trailing partial block and leaves invalid padding in place rather than failing, so its + /// output can only be a whole number of blocks minus at most one; RC4 keeps its length + /// whatever the bytes are. An input shorter than the IV has nothing left to decode. + #[test] + fn damaged_ciphertext_never_panics(alg in algorithms(), enc in prop::collection::vec(any::(), 0..64)) { + let h = make(alg, "user", "owner"); + for cut in 0..=enc.len() { + let broken = &enc[..cut]; + let out = h.decrypt_string(7, 3, broken); + if alg == Algorithm::Rc4_40 || alg == Algorithm::Rc4_128 { + prop_assert_eq!(out.len(), cut, "RC4 must be length-preserving ({:?})", alg); + } else { + let blocks = cut.saturating_sub(16) / 16; + prop_assert!(out.len() <= blocks * 16, "{:?}: {} bytes of ciphertext yielded {} decrypted bytes", alg, cut, out.len()); + prop_assert!(blocks * 16 - out.len() <= 16, "{:?}: padding removed more than one block", alg); + } + } + + // Flip every byte of a real ciphertext in turn: no position may crash the reader. + let full = h.encrypt_string(7, 3, b"twenty-four bytes of PDF content"); + for i in 0..full.len() { + let mut mutated = full.clone(); + mutated[i] ^= 0xFF; + let _ = h.decrypt_string(7, 3, &mutated); + } + } + + /// A crypt filter name decides whether content is encrypted at all: PDF 2.0 permits + /// `/StmF /Identity` with attachments still encrypted, and a name that appears in neither `/CF` + /// nor as `Identity` resolves to Identity rather than failing. The reported answer and the + /// bytes must agree, at every length including empty (where AES still adds a whole block). + /// `/EFF` is set here so embedded files and normal streams must take *different* paths: the + /// mix-up that leaves an attachment in plain sight, or encrypts a stream its own filter says + /// should not be. And sometimes `/CF` carries an entry *named* `Identity` that claims + /// `/CFM /AESV2`: a producer writing nonsense. The name wins and the bytes stay plain, since + /// encrypting what both sides agreed is plaintext would write a file other readers cannot open. + #[test] + fn crypt_filters_decide_what_gets_encrypted( + data in payloads(), + name in prop::sample::select(vec!["Identity", "StdCF", "Nope"]), + bogus_identity_in_cf in any::(), + ) { + let mut d = make(Algorithm::Aes256, "", "owner").dict().clone(); + d.str_f = name.as_bytes().to_vec(); + d.stm_f = b"Identity".to_vec(); + d.ef_f = b"StdCF".to_vec(); + if bogus_identity_in_cf { + d.crypt_filters.push((b"Identity".to_vec(), Method::Aes256)); + } + let h = SecurityHandler::open(d, ID, None).unwrap(); + + // Only `StdCF` really encrypts; `Identity` does not even when `/CF` defines it, and a name + // in neither place resolves to Identity rather than failing. + let strings_encrypted = name == "StdCF"; + prop_assert_eq!(h.encrypts_strings(), strings_encrypted, "/StrF /{} reported {}", name, strings_encrypted); + + let enc = h.encrypt_string(3, 0, &data); + if strings_encrypted { + prop_assert_ne!(&enc, &data, "/StrF /{} left strings in plain sight", name); + prop_assert!(enc.len() > data.len(), "AES always pads: {} bytes of ciphertext for {} plaintext", enc.len(), data.len()); + prop_assert_eq!(h.decrypt_string(3, 0, &enc), &data[..], "/StrF /{} round trip", name); + } else { + prop_assert_eq!(&enc, &data, "/StrF /{} must pass strings through", name); + } + + // A normal stream follows `/StmF /Identity` here, whatever the string filter says. + prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Normal), &data[..], "normal streams follow /StmF Identity"); + // An embedded file follows `/EFF /StdCF`, so it is encrypted at every length — empty + // included, where AES still emits a padded block — and comes back intact. + let ef = h.encrypt_stream(3, 0, &data, StreamKind::EmbeddedFile); + prop_assert_ne!(&ef, &data[..], "/EFF /StdCF left an embedded file in plain sight at {} bytes", data.len()); + prop_assert_eq!(h.decrypt_stream(3, 0, &ef, StreamKind::EmbeddedFile), &data[..], "embedded file round trip"); + // An unknown named filter is Identity, not an error; a known one uses its real method. + prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"Missing")), &data[..], "an unknown crypt filter must not encrypt"); + prop_assert_ne!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"StdCF")), &data[..], "a filter named /StdCF must encrypt"); + } + + /// Object keys are scoped to the object (Algorithm 1, §7.6.3.3): RC4 and AES-128 fold the + /// object number and generation into the key, so another object must not read these bytes back. + /// AES-256 is the documented exception — revisions 5 and 6 use the file key for every object. + /// The comparisons that decrypt under a foreign number carry the weight: wrong keystream bytes + /// match the plaintext only by a chance of one in 256 per byte. Checking that *encryption* + /// differs would prove less, because our initial vector already covers `/Num`. + #[test] + fn object_keys_are_scoped_to_their_object(alg in prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128]), data in prop::collection::vec(any::(), 8..40)) { + let h = make(alg, "user", "owner"); + + let enc = h.encrypt_string(100, 0, &data); + prop_assert_eq!(h.decrypt_string(100, 0, &enc), &data[..], "{:?} reads its own object back", alg); + prop_assert_ne!(h.decrypt_string(101, 0, &enc), &data[..], "{:?}: another object number must not decrypt these bytes", alg); + prop_assert_ne!(h.decrypt_string(100, 1, &enc), &data[..], "{:?}: another generation must not decrypt these bytes", alg); + + // Algorithm 1 folds the LOW THREE bytes of `/Num` into the key. Two directions are pinned: + // numbers that differ inside those three bytes must separate (a fold to `[..2]` would let + // object 0 and object 65_536 share a key, silently handing one object's plaintext to another), + // and numbers that differ only ABOVE them must share it — the fold is three bytes by design, + // so a widening to four would break this and nothing else in the crate notices. + let low = h.encrypt_string(0, 0, &data); + prop_assert_ne!(&h.decrypt_string(65_536, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide; 0 and 65_536 must not share a key", alg); + prop_assert_ne!(&h.decrypt_string(1 << 23, 0, &low)[..], &data[..], "{:?}: the third byte of /Num must still reach the key", alg); + prop_assert_eq!(&h.decrypt_string(1 << 24, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide, so a difference above bit 23 shares the key by design", alg); + + let r6 = make(Algorithm::Aes256, "user", "owner"); + let enc6 = r6.encrypt_string(100, 0, &data); + prop_assert_eq!(&r6.decrypt_string(101, 3, &enc6)[..], &data[..], "AES-256 uses the file key for every object (§7.6.3.3)"); + // ...which is exactly why the initial vector has to vary: revisions 5 and 6 reuse one key + // for the whole file, so two objects holding the same bytes must still produce different + // ciphertext. Identical bytes for a different `/Num` would be CBC initial-vector reuse — + // the flaw that leaks structure in encrypted data. + prop_assert_ne!(&r6.encrypt_string(101, 0, &data)[..], &enc6[..], "AES-256 reused the same initial vector for two objects"); + prop_assert_ne!(&r6.encrypt_string(100, 1, &data)[..], &enc6[..], "AES-256 reused the same initial vector across generations"); + } + + /// A file created for `user` and `owner` opens for exactly the passwords whose effective bytes + /// equal one of theirs, and reports the role that match earned — owner first, since viewers try + /// `/O` before `/U`. Everything else is `WrongPassword`: never an opening, and no panic on odd + /// Unicode. This is the rule #1185 became (bytes decide, so a soft hyphen alone is no password + /// and U+FF21 is "A"), stated for all inputs rather than the three examples in `src/tests.rs`. + /// It also pins #1762's upgrade path: a user session gains owner rights on the owner password + /// and on nothing else. + #[test] + fn authentication_accepts_exactly_the_matching_passwords( + alg in algorithms(), + mut user in passwords(), + owner in prop::sample::select(vec!["owner".to_string(), String::new(), "Owner\u{AD}".into(), "\u{FF21}".into()]), + extra in passwords(), + ) { + // `user` may have been grown past the 32-byte cut above; create with that spelling. + // Grow the legacy password PAST the 32-byte cut BEFORE the file is built from it, so a + // partner differing only beyond byte 32 is the same password to this file. + if alg != Algorithm::Aes256 { + user = format!("{user}{}", "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2)); + } + + let h = make(alg, &user, &owner); + let d = h.dict().clone(); + let user_bytes = effective_password(alg, &user); + let owner_bytes = owner_target(alg, &user, &owner); + + // A collision engineered by construction, not by luck. Revisions 2-4 cut the password at + // 32 bytes (Algorithm 2), so any spelling that agrees on the first 32 is ONE password; + // revisions 5-6 run SASLprep, which deletes U+00AD. Growing past byte 32 guarantees the + // partner is a different string with identical bytes at every draw — earlier drafts here + // claimed `(` alone did it, which is false: appending "(" to a short password DISPLACES the + // padding (pad("a") is [61,28,BF..], pad("a(") is [61,28,28,BF..]), and this alphabet draws + // at most 11 characters, so chance produced zero legacy collisions in 1,566 measured cases. + + let mut suspects: Vec = vec![user.clone(), owner.clone(), String::new()]; + // Near misses are PREPENDED: appending to a legacy password already past the cut would be + // another collision rather than a refusal, which would silently thin the negative testing. + let mut near_misses: Vec = vec![format!("q{user}"), extra.clone()]; + if alg == Algorithm::Aes256 && !user.is_empty() { + near_misses.push(format!("{user}q")); + } + + let collision_partner = if alg == Algorithm::Aes256 { format!("{user}\u{AD}") } else { format!("{user}Z") }; + prop_assert_ne!(&collision_partner[..], &user[..], "the collision partner must be a different string"); + prop_assert_eq!(effective_password(alg, &collision_partner), effective_password(alg, &user), "{:?}: {:?} must become the same bytes as {:?}", alg, collision_partner, user); + + // The mistake itself, pinned so it cannot come back: a short legacy password plus "(" is NOT the + // same password, because it shifts the padding by one byte instead of filling a slot. + if alg != Algorithm::Aes256 { + prop_assert_ne!(effective_password(alg, "a("), effective_password(alg, "a"), "appending ( to a short password displaces the padding; that is not a collision"); + prop_assert_eq!(effective_password(alg, &"a".repeat(31)), effective_password(alg, &format!("{}(", "a".repeat(31))), "at exactly 31 characters ( does land in the padding slot, and THAT is a collision"); + } + + suspects.extend(near_misses); + suspects.push(collision_partner.clone()); + suspects.dedup(); + + for suspect in &suspects { + let want = expected_auth(alg, &user_bytes, &owner_bytes, suspect); + match (want, SecurityHandler::open(d.clone(), ID, Some(suspect))) { + (Some(role), Ok(got)) => { + prop_assert_eq!(got.auth(), role, "{:?}: {:?} should open as {:?}", alg, suspect, role); + prop_assert_eq!(got.file_key(), h.file_key(), "{:?}: whichever password opens the file, the key is the same", alg); + } + (Some(role), Err(e)) => prop_assert!(false, "{:?}: {:?} must open as {:?} but gave {:?}", alg, suspect, role, e), + (None, Ok(got)) => prop_assert!(false, "{:?}: {:?} is not a password for this file, yet it opened as {:?}", alg, suspect, got.auth()), + (None, Err(CryptError::WrongPassword)) => {} + (None, Err(e)) => prop_assert!(false, "{:?}: {:?} gave {}, expected WrongPassword", alg, suspect, e), + } + + // The owner password upgrades a user session; nothing else does. When the two share + // bytes, `create` gave /O the user's own bytes (revisions 2–4) or left it empty + // (AES-256, #1185's sharp edge), so the open password *is* an owner password here. + if let Ok(session) = SecurityHandler::open(d.clone(), ID, Some(&user)) { + let is_owner_password = expected_auth(alg, &user_bytes, &owner_bytes, suspect) == Some(Auth::Owner); + let upgrade = session.authenticate_owner(ID, suspect).map(|u| u.auth()); + if is_owner_password { + prop_assert_eq!(upgrade, Ok(Auth::Owner), "{:?}: {:?} must unlock owner rights", alg, suspect); + } else { + prop_assert_eq!(upgrade, Err(CryptError::WrongPassword), "{:?}: {:?} must not grant owner rights", alg, suspect); + } + } + } + + // `None` means no password was offered: the handler tries the empty string, and nothing + // else (a file with a real open password never opens silently). + let want_none = expected_auth(alg, &user_bytes, &owner_bytes, ""); + match (want_none, SecurityHandler::open(d.clone(), ID, None)) { + (Some(role), Ok(got)) => prop_assert_eq!(got.auth(), role, "{:?}: no password opened as {:?}", alg, got.auth()), + (None, Ok(got)) => prop_assert!(false, "{:?}: the empty password is not valid, yet no password opened as {:?}", alg, got.auth()), + (Some(role), Err(e)) => prop_assert!(false, "{:?}: the empty password should open as {:?} but gave {:?}", alg, role, e), + (None, Err(CryptError::WrongPassword)) => {} + (None, Err(e)) => prop_assert!(false, "{:?}: no password gave {}, expected WrongPassword", alg, e), + } + } + + /// The AES-CBC and RC4 primitives taken directly: a damaged file's `/Length` and crypt filters + /// hand them keys and data of shapes `create` would never build, so they must invert each other + /// for any key length (`Cipher::new` takes AES-256 at 32 bytes and zero-padded AES-128 below + /// it, identically on both sides of the trip) and every payload length. + #[test] + fn primitives_are_invertible_for_any_key_and_length( + key in prop::collection::vec(any::(), 1..40), + data in payloads(), + iv in prop::collection::vec(any::(), 16..24), + ) { + let iv: [u8; 16] = *iv.as_slice().first_chunk::<16>().expect("the iv strategy draws at least 16 bytes; a silent zero fallback would hide a generator change"); + + let enc = aes_cbc_encrypt(&key, &iv, &data, true); + prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &enc, true), &data[..], "padded AES round trip at {} bytes", data.len()); + prop_assert!(enc.len() >= 16 && enc.len().is_multiple_of(16), "PKCS#5 makes whole blocks: {} bytes for {} plaintext", enc.len(), data.len()); + + // Without padding, a partial block is zero-padded rather than refused, and extra trailing + // bytes decrypt to more output instead of being dropped. + let raw = aes_cbc_encrypt(&key, &iv, &data, false); + prop_assert_eq!(raw.len(), data.len().div_ceil(16) * 16, "unpadded AES output is whole blocks"); + let mut longer = raw.clone(); + longer.extend_from_slice(b"0123456789abcdef"); + prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &longer, false).len(), raw.len() + 16, "a whole extra block decrypts to a whole extra block"); + + // RC4 undoes itself at every length; an empty key encrypts nothing, which is why the + // handler never derives an empty object key. + prop_assert_eq!(rc4(&key, &rc4(&key, &data)), &data[..], "RC4 round trip at {} bytes", data.len()); + prop_assert_eq!(rc4(&[], &data), &data[..], "an empty RC4 key passes bytes through"); + } + + /// `/Perms` certifies the permission bits and `/EncryptMetadata`: decrypted with the file key it + /// must hold `/P` little-endian, four set bytes, 'T' or 'F', and the `adb` sentinel (Algorithm 10). + /// Nothing else in this crate reads it — `cos` writes it out and no code takes it back apart — so + /// without this property a flipped metadata byte would tell every other viewer the wrong thing about + /// our own file while all our tests stayed green. The older revisions we create emit no `/Perms` + /// at all, which is recorded here as well so a change in what goes out shows up rather than + /// shipping quietly. + #[test] + fn perms_value_agrees_with_the_permissions_it_certifies(permissions in any::(), encrypt_metadata in any::()) { + let h = create( + &NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x5e; 32] }, + ID, + ) + .unwrap(); + + prop_assert_eq!(h.dict().perms.len(), 16, "/Perms is one encrypted block"); + let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &h.dict().perms, false); + + // Bits 1-2 clear and the reserved bits set (§7.6.4.2 Table 22) is what `/P` must say, and the + // encrypted certificate has to repeat it byte for byte. + let normalised = (permissions | !0xFFF | 0xC0) & !0x3; + prop_assert_eq!(&open[..4], &normalised.to_le_bytes(), "/Perms must carry /P"); + prop_assert_eq!(h.dict().p, normalised, "the /P written must match the permission bits drawn"); + prop_assert_eq!(&open[4..8], &[0xFF; 4], "the four bytes following /P are set"); + prop_assert_eq!(open[8], if encrypt_metadata { b'T' } else { b'F' }, "/Perms must report /EncryptMetadata"); + prop_assert_eq!(&open[9..12], b"adb", "the adb sentinel must be present"); + + // A user session's answers come from those same bits, not from the owner shortcut. + let as_user = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap(); + prop_assert_eq!(as_user.permissions().print(), normalised & 0b100 != 0, "/P bit 3 decides printing"); + prop_assert_eq!(as_user.permissions().copy(), normalised & 0b1_0000 != 0, "/P bit 5 decides copying"); + prop_assert!(!as_user.permissions().owner, "the user password must not report owner rights"); + + // Bytes 13-16 of /Perms are the non-repeating filler. Nothing reads them, so pinning that + // they move with the seed is the only way to notice a producer that freezes them at zero. + let other = create( + &NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x11; 32] }, + ID, + ) + .unwrap(); + let open_other = aes_cbc_decrypt(other.file_key(), &[0u8; 16], &other.dict().perms, false); + prop_assert_ne!(&open[12..16], &open_other[12..16], "the four filler bytes of /Perms must follow the seed, not sit constant"); + + // Revisions below 5 carry no /Perms; pinned so the boundary of what we emit is explicit. + for older in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] { + let legacy = make(older, "user", "owner"); + prop_assert!(legacy.dict().perms.is_empty(), "{:?} must emit no /Perms", older); + } + } + /// Revisions 4 mix four set bytes into every file key when `/EncryptMetadata` is false (Algorithm + /// 2, step f), so the same password over the same `/ID` must produce a *different* key than when + /// metadata is encrypted. Every other test in this crate builds `encrypt_metadata: true` only, + /// which is exactly why that step is reachable but never walked: switching it off entirely changed + /// nothing any test could see. This also records the rule that revisions below 4 always encrypt + /// metadata whatever was asked, so the boundary between the two is named rather than assumed. + #[test] + fn metadata_flag_changes_the_key_it_claims_to_change(data in payloads()) { + let build = |encrypt_metadata: bool| { + create(&NewEncryption { algorithm: Algorithm::Aes128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata, seed: [0x5e; 32] }, ID).unwrap() + }; + let (yes, no) = (build(true), build(false)); + + // Algorithm 2, step f: revisions 4 hash four set bytes into every key when metadata stays + // plain, so two files with identical passwords and the same /ID cannot share a key. + prop_assert_ne!(yes.file_key(), no.file_key(), "R4 must fold four set bytes into the key when /EncryptMetadata is false (Algorithm 2, step f)"); + prop_assert_eq!(no.dict().encrypt_metadata, false, "the flag must reach the dictionary"); + prop_assert!(!no.encrypts_metadata(), "/EncryptMetadata false must report itself honestly"); + prop_assert!(yes.encrypts_metadata(), "and true must report true"); + + // Both still open on the same password and round trip at every length: a derivation that + // drifted would show up as an unreadable file rather than passing quietly. + for h in [&yes, &no] { + let open = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap(); + prop_assert_eq!(open.file_key(), h.file_key(), "the user password must still derive this file's key"); + let enc = h.encrypt_string(7, 0, &data); + prop_assert_eq!(&open.decrypt_string(7, 0, &enc)[..], &data[..], "round trip at {} bytes", data.len()); + } + + // And the two files must not read each other's content: different keys, different plaintext. + if data.len() >= 8 { + prop_assert_ne!(&yes.decrypt_string(7, 0, &no.encrypt_string(7, 0, &data))[..], &data[..], "a key built for plain metadata must not decrypt a file whose keys encrypt it"); + } + + // Revisions below 4 always encrypt metadata, whatever the caller asked for. + let legacy = create(&NewEncryption { algorithm: Algorithm::Rc4_128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata: false, seed: [0x5e; 32] }, ID).unwrap(); + prop_assert!(legacy.dict().encrypt_metadata && legacy.encrypts_metadata(), "V<4 always encrypts metadata, as create() documents"); + } + +} diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 12008dee87..2b6c9d13a6 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -11,7 +11,9 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi}; +use pdfcraft_fonts::{ + GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi, +}; use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged}; @@ -239,27 +241,28 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> { let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); for para in t.text.split('\n') { - let arabic = para.chars().any(is_arabic); + // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. + let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); let thai = para.chars().any(is_thai); if thai - && !arabic + && !rtl_script && let Ok(lines) = thai_lines(t, para, width) { out.extend(lines.into_iter().map(|line| (line, false))); continue; } - let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; + let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; // Arabic words are shaped one by one (joining stops at spaces), so a line's width is the // sum of its words' and shaping stays linear in the paragraph's length. The space is the // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths // of an em apart. - let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; + let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) }; let space = measure(" "); let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; let candidate_w = - if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; if !line.is_empty() && candidate_w > width { out.push((std::mem::take(&mut line), rtl)); line = word.to_string(); @@ -313,9 +316,16 @@ fn is_arabic(c: char) -> bool { matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) } +/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an +/// item with any of them is drawn with the craft-fonts Hebrew face. +fn is_hebrew(c: char) -> bool { + matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F) +} + /// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn -/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no -/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open. +/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't +/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor +/// keeps it open. pub fn first_undrawable(t: &AddedText) -> Option { if t.text.chars().any(is_thai) { if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) { @@ -324,11 +334,13 @@ pub fn first_undrawable(t: &AddedText) -> Option { let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() }; return first_undrawable(&rest); } - // Arabic that nothing can draw: there's no face, or the face lacks it. - if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) { + // Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it. + if let Some(c) = + t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c))) + { return Some(c); } - if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) { + if !shaped_by_a_face(t) { // Drawn line by line in the item's standard font (`\n` splits lines). Without the face // that is also how `draw` redraws an existing item, so it's checked that way. return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); @@ -343,6 +355,13 @@ pub fn first_undrawable(t: &AddedText) -> Option { }) } +/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with +/// [`draw_shaped`]). +fn shaped_by_a_face(t: &AddedText) -> bool { + (pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic)) + || (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew)) +} + /// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). fn shaped_arabic(c: char) -> bool { is_arabic(c) && !is_bidi_control(c) @@ -391,12 +410,16 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl); // Runs lie on character boundaries; a missing slice is skipped, never a panic. let Some(text) = line.get(run) else { continue }; - // Arabic letters take the Arabic face; so do spaces and punctuation between them, which the - // shaper then mirrors. Latin text (and what the face lacks) keeps the item's font. + // Arabic and Hebrew letters take their face; so do spaces and punctuation between them + // (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what + // the faces lack) keeps the item's font. let mut segments: Vec<(Option, String)> = Vec::new(); for c in text.chars().filter(|c| !is_bidi_control(*c)) { + let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew)); let script = if is_thai(c) { Some(ShapedScript::Thai) + } else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) { + Some(ShapedScript::Hebrew) } else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) { Some(ShapedScript::Arabic) } else { @@ -414,6 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f6 pieces.push(if let Some(script) = script { let shaped = match script { ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?, + ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?, ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?, }; Piece::Shaped(shaped) @@ -450,6 +474,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError { }) } +fn hebrew_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)" + .into(), + GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""), + GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"), + }) +} + fn thai_error(e: GlyphError, text: &str) -> EditError { EditError::Invalid(match e { GlyphError::NoFont => { @@ -479,7 +513,7 @@ const GLYPHS_PER_FONT: usize = 240; /// its pieces in drawing order and its width at the item's size. type LaidLine = (String, bool, Vec, f64); -/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs +/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs /// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), /// with a ToUnicode map for copy and search. Other text uses the item's standard font. fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { @@ -524,6 +558,7 @@ fn draw_laid_shaped( if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) { entry.insert(outline(c.script, *id).map_err(|e| match c.script { ShapedScript::Arabic => arabic_error(e, &c.text), + ShapedScript::Hebrew => hebrew_error(e, &c.text), ShapedScript::Thai => thai_error(e, &c.text), })?); } @@ -663,6 +698,7 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash Some(script) => script.font().map_or( match script { ShapedScript::Arabic => "Arabic", + ShapedScript::Hebrew => "Hebrew", ShapedScript::Thai => "Thai", }, |f| f.family, @@ -697,8 +733,9 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash } /// Content and resources for an item; `view` maps display space to user space. `taken` holds the -/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face -/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it. +/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or +/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to +/// move it. fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { let mut res = Dict::new(); let mut out = format!( @@ -724,7 +761,11 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: } let mut fonts = Dict::new(); fonts.set(name.clone().into_bytes(), Object::Dict(font)); - if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) { + let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing); + if t.text.chars().any(is_thai) + || face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some()) + || face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some()) + { draw_shaped(doc, t, taken, &mut fonts, &mut out)?; res.set(b"Font".to_vec(), Object::Dict(fonts)); out.extend_from_slice(b"Q\n"); diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index a3f6c886a2..8202c18d83 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -6,7 +6,8 @@ //! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden //! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened //! comments take their pop-ups and replies with them; flattened widgets are removed from the -//! form's field tree. +//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays +//! as it is, so no comment or field value is lost with nothing in its place. use std::collections::HashSet; @@ -154,6 +155,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result = HashSet::new(); let mut gone_inline: HashSet = HashSet::new(); + // Selected, but nothing was drawn for it: it stays, pop-up and reply handling included. + let mut undrawn: HashSet = HashSet::new(); // A viewer draws many comments from their dictionary even when the file stores no // appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so // flattening keeps a comment that still has ink on screen instead of deleting it. @@ -199,9 +202,18 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -218,9 +230,13 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result Result, Edi pub enum ImageChange { /// Apply a user-space transform after its placement (move, resize, rotate, flip). Transform([f64; 6]), + /// Show only this part of it (a box in user space): a clipping path around the drawing, + /// inside its own scope so a later move or resize carries it along. The pixels stay where + /// they are; nothing is scaled. + Crop([f64; 4]), /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, @@ -457,6 +461,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Crop(keep) => { + return crop_image(doc, &p, &all, &img, *keep); + } ImageChange::Replace(r) => { if img.is_form { return Err(EditError::Invalid( @@ -652,6 +659,86 @@ fn transform_image( Ok(()) } +/// Crop: clip the image's drawing to `keep` (user space). The clip goes just inside the scope +/// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in +/// that scope's coordinates as the box's four corners, so a turned image crops to an upright +/// box too and a later transform of the scope moves the crop with the pixels. +fn crop_image( + doc: &mut Document, + page: &pdfcraft_model::Page, + streams: &[(Object, Vec)], + image: &PageImage, + keep: [f64; 4], +) -> Result<(), EditError> { + if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into())); + } + let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])]; + if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { + return Err(EditError::Invalid("the crop box has no area".into())); + } + let mut offset = 0_usize; + for (_, data) in streams.iter().take(image.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; + let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); + if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) { + return Err(EditError::Invalid("the crop box is too large for the image's placement".into())); + } + let clip: Vec = corners + .iter() + .enumerate() + .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) + .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) + .collect(); + // The image's own earlier crop (a clipping path right after the scope's `q`) gives way to + // the new one, so a crop can grow again as well as shrink. + let old_clip: Option> = (start != end) + .then(|| { + let mut k = start + 1; + while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + k += 1; + } + (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) + .then_some(start + 1..k + 2) + }) + .flatten(); + let changed = crate::text::splice(&pieces, &ops, |index| { + if start == end && index == target { + // No scope of its own yet: one around the drawing, with the crop inside. + let mut out = vec![Op::new("q", vec![])]; + out.extend(clip.iter().cloned()); + out.extend(ops.get(index).cloned()); + out.push(Op::new("Q", vec![])); + (out, false) + } else if start != end && index == start + 1 { + // Inside the scope's `q`, before whatever it draws (in place of its old crop). + (clip.clone(), old_clip.is_none()) + } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (Vec::new(), false) + } else { + (Vec::new(), true) + } + }); + let contents = crate::text::rewritten_contents(doc, streams, changed); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Plan all transforms before changing a stream; indexes refer to the original inventory. /// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries. pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 0fb7e3eeea..515f17d8da 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -73,6 +73,17 @@ impl Default for HeaderFooter { } } +impl HeaderFooter { + /// The first character [`add_header_footer`] would refuse, because the standard fonts can't + /// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the + /// template), so checking the templates, split as `expand`'s output is, covers every page + /// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview + /// never offers text that applying refuses (#2013). + pub fn first_undrawable(&self) -> Option { + self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi) + } +} + /// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in /// the document, an image (drawn into a unit square) or a form (a page of a PDF). #[derive(Clone, Copy, Debug, PartialEq)] @@ -104,6 +115,17 @@ pub struct Watermark { pub offset: [f64; 2], } +impl Watermark { + /// The first character of a text watermark [`add_watermark`] would refuse, because the + /// standard fonts can't draw it (#125). A picture watermark draws no text. + pub fn first_undrawable(&self) -> Option { + match self.source { + Some(_) => None, + None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi), + } + } +} + impl Default for Watermark { fn default() -> Self { Self { @@ -383,16 +405,6 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { const END: &str = "EMC\nQ\n"; -/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each -/// such character would become a `?` on the page (#125). `lines` must split the text the way the -/// caller draws it, so a `\r` that would be drawn is refused too. -pub(crate) fn drawable<'a>(lines: impl IntoIterator) -> Result<(), EditError> { - match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) { - Some(c) => Err(undrawable(c)), - None => Ok(()), - } -} - /// The refusal for page text with `c`, which nothing here can draw. pub(crate) fn undrawable(c: char) -> EditError { EditError::Invalid(undrawable_message(c)) @@ -418,9 +430,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, if hf.text.iter().all(|t| t.trim().is_empty()) { return Err(EditError::Invalid("type the header or footer text first".into())); } - // Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking - // the templates, split as `expand`'s output is, covers every page. - hf.text.iter().try_for_each(|t| drawable(t.lines()))?; + if let Some(c) = hf.first_undrawable() { + return Err(undrawable(c)); + } if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { return Err(EditError::Invalid("invalid font size or margins".into())); } @@ -438,18 +450,26 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, u32::MAX ))); } + // The same goes for Bates numbers: the last selected page must still fit in a u64. + let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1); + if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() { + return Err(EditError::Invalid(format!( + "the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})", + pages.len(), + u64::MAX + ))); + } if replace { remove_marks(doc, pages, MarkKind::HeaderFooter)?; } let all = page_list(doc); let count = all.len(); - let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1); for (k, &i) in pages.iter().enumerate() { let page = &all[i]; let (w, h) = page.display_size(doc); let number = u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?; - let bates = bates0 + k as u64; + let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?; let size = hf.font_size; let [top, bottom, left, right] = hf.margins; let mut body: Vec = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes(); @@ -498,8 +518,8 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac if text.is_empty() && wm.source.is_none() { return Err(EditError::Invalid("type the watermark text first".into())); } - if wm.source.is_none() { - drawable(text.lines())?; + if let Some(c) = wm.first_undrawable() { + return Err(undrawable(c)); } if !(wm.opacity.is_finite() && wm.rotation.is_finite() diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 912cb3c904..e4c49d7411 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -115,6 +115,23 @@ fn header_start_number_that_would_wrap_is_refused_without_drawing() { assert_eq!(marks_present(&doc).len(), 1); } +#[test] +fn header_bates_start_that_would_overflow_is_refused_without_drawing() { + // A Bates start of u64::MAX has no room for a second page. The run must be refused up front + // instead of overflowing (a panic in debug builds, a wrap to 0 in release builds). + let mut doc = fixture(); + let hf = HeaderFooter { + text: [String::new(), String::new(), "<>".into(), String::new(), String::new(), String::new()], + ..HeaderFooter::default() + }; + let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err(); + assert!(err.to_string().contains("Bates"), "{err}"); + assert!(marks_present(&doc).is_empty(), "nothing is drawn when the run is refused"); + // A single page still fits the start number itself. + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + assert_eq!(marks_present(&doc).len(), 1); +} + #[test] fn replace_and_remove_restore_the_original_content() { let mut doc = fixture(); @@ -294,6 +311,58 @@ fn flattening_fields_drops_the_ones_on_no_page() { assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays"); } +/// #1794: a form field whose widget has no usable appearance (none stored, or none for its +/// selected state) has nothing to draw. Flattening used to remove it anyway, losing its value +/// with no ink in its place. It stays, in the page and in the form, like a comment that can't be +/// drawn; a field with an appearance is still baked in and removed. +#[test] +fn flattening_keeps_a_valued_widget_it_cannot_draw() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 10 150 40] /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Yes /AS /Yes /Rect [10 50 30 70] /F 4 /AP << /N << /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /V (Bob) /Rect [10 90 150 120] /F 4 /AP << /N 8 0 R >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn"); + let doc = reopen(&doc); + let refs = |o: &Object| -> Vec { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() }; + let p = &pdfcraft_model::pages(&doc)[0]; + assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [5, 6], "the undrawable widgets stay on the page"); + let root = doc.root().unwrap(); + let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap()); + assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [5, 6], "and in the form"); + let value = |n: u32| doc.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().unwrap().get(b"V").cloned(); + assert_eq!(value(5), Some(Object::String(pdfcraft_cos::PdfString::literal(b"Ada".to_vec())))); + assert_eq!(value(6), Some(Object::name("Yes"))); +} + +/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to +/// is flattened; the comment's pop-up still goes with it. +#[test] +fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>", + "<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>", + "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let left: Vec = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect(); + assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone"); +} + /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an /// appearance whose text Helvetica can't draw (another program's), and a comment or form field /// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew @@ -641,6 +710,82 @@ fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() { } } +#[test] +fn added_hebrew_text_is_drawn_right_to_left_and_stays_searchable() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 400.0, 700.0], + text: "שלום (עולם) 2026 World".into(), + size: 14.0, + align: Align::Right, + ..AddedText::default() + }; + if pdfcraft_fonts::document_hebrew_font(false).is_none() { + eprintln!("skipping the Hebrew drawing checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)"); + assert_eq!(first_undrawable(&text), Some('ש')); + let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err(); + assert!(matches!(&err, EditError::Invalid(m) if m.contains("U+05E9")), "{err:?}"); + assert!(!doc.is_modified(), "nothing is written when the font is missing"); + return; + } + assert_eq!(first_undrawable(&text), None); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + // Points stay on their letters; a Hebrew line in an Arabic item and Arabic in a Hebrew one. + for other in ["שָׁלוֹם", "שלום مرحبا"] { + let other = AddedText { text: other.into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() }; + if other.text.chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)) && pdfcraft_fonts::document_arabic_font().is_none() { + continue; + } + add_content(&mut doc, 0, &Content::Text(other)).unwrap(); + } + let doc = reopen(&doc); + let added = list_added(&doc); + let Content::Text(t) = &added[0].content else { panic!() }; + assert_eq!(t.text, "שלום (עולם) 2026 World", "the logical text is kept for editing"); + let s = streams(&doc, 0); + let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap(); + // A right-to-left paragraph: "World", typed last, is drawn left of the Hebrew. + let x_before = |s: &str, at: usize| -> f64 { + let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8; + s[tm..].split(' ').next().unwrap().parse().unwrap() + }; + let latin_x = x_before(item, item.find("(World) Tj").unwrap()); + let hebrew_at = item.find("/PCAr").unwrap(); + let hebrew_x = x_before(item, item[hebrew_at..].find(" Tj").unwrap() + hebrew_at); + assert!(latin_x < hebrew_x, "{item}"); + // Copy and search get the typing order back. + let logical: String = t.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + assert!(item.contains(&format!("/ActualText ")), "{item}"); + // The Hebrew glyphs come from a Type 3 font named after the face, whose ToUnicode map gives + // back each letter. + let p = &pdfcraft_model::pages(&doc)[0]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone(); + let shaped: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect(); + let mut mapped = String::new(); + for f in &shaped { + assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..])); + mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap()); + } + for letter in ["05E9", "05DC", "05D5", "05DD", "05E2"] { + assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}"); + } + let font_name = |f: &Dict| doc.resolve(f.get(b"FontDescriptor").unwrap()).as_dict().unwrap().name(b"FontName").map(<[u8]>::to_vec); + assert!(shaped.iter().any(|f| font_name(f).as_deref() == Some(&b"NotoSansHebrew"[..]))); + // A pointed letter is one glyph with its points, mapped back whole. + assert!(mapped.contains("<05E905"), "{mapped}"); +} + +#[test] +fn odd_hebrew_text_never_panics() { + let mut doc = fixture(); + let long = "שלום עולם ".repeat(2_000); + for s in ["\u{202E}ש\u{05B8}\u{05B8} (]", "\u{05B8}", "א\u{200F}\u{2067}b\u{2069}", "א\n\nב\tc", "׳״ 456", long.as_str()] { + let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() })); + assert_eq!(r.is_ok(), pdfcraft_fonts::document_hebrew_font(false).is_some(), "{:?}: {r:?}", s.chars().take(12).collect::()); + } +} + /// The `PCAr` font names in page `page`'s resources. fn arabic_font_names(doc: &Document, page: usize) -> Vec { let p = &pdfcraft_model::pages(doc)[page]; @@ -1277,6 +1422,74 @@ fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() { } } +/// A Chrome (Skia) style subset: Type0/Identity-H over a TrueType program, CID = glyph id. +/// `/W` lists most used glyphs, writes 0 for some it dropped, and leaves `a` and `y` to `/DW`; +/// `z` has a ToUnicode entry but no glyph in the program (its CID is past the last glyph). +fn skia_subset_page() -> Document { + // Inter's glyph ids: P 375, o 790, l 736, a 507, n 773, d 597, G 141, e 614, r 852, m 766, y 998, space 1777. + let mut doc = text_page( + "BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET \ + BT /F3 12 Tf 72 650 Td <008D0266035402FE01FB030503E606F101FB0305025506F102FE031603540266> Tj ET", + ); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 13 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> \ + <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> \ + <06F1> <0020> <1000> <007A> endbfchar", + ))); + let widths: Vec = [(0x8D, 746), (0x177, 639), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)] + .into_iter() + .chain([(0x354, 376), (0x355, 0), (0x356, 0), (0x6F1, 281)]) + .flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])]) + .collect(); + let descriptor = Dict::from_iter([ + (b"Type".to_vec(), Object::name("FontDescriptor")), + (b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"Flags".to_vec(), Object::Int(32)), + (b"FontFile2".to_vec(), Object::Ref(program)), + ]); + let cid_font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"CIDToGIDMap".to_vec(), Object::name("Identity")), + (b"DW".to_vec(), Object::Int(562)), + (b"W".to_vec(), Object::Array(widths)), + (b"FontDescriptor".to_vec(), Object::Dict(descriptor)), + ]); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + (b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])), + (b"ToUnicode".to_vec(), Object::Ref(unicode)), + ]); + let p = pdfcraft_model::pages(&doc)[0].obj; + let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap(); + doc +} + +#[test] +fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() { + let doc = skia_subset_page(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["Poland", "Germany and more"]); + // Deleting words only, and replacing a word with one drawn elsewhere on the page (`y` and `a` + // have no /W entry): every glyph is in the subset, so the font stays. + for (block, text) in [(1, "Germany"), (0, "Germany")] { + let mut doc = doc.clone(); + assert_eq!(text::replace_block(&mut doc, 0, block, text).unwrap().substituted, None, "{text}"); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines.iter().find(|l| l.text == text).unwrap().font, "F3"); + } + // `z` maps to a CID the program has no glyph for: that still needs another font. + let mut doc = doc.clone(); + assert!(text::replace_block(&mut doc, 0, 0, "Graz").unwrap().substituted.is_some()); +} + #[test] fn spaces_in_another_subset_do_not_split_a_line() { let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET"); @@ -2266,6 +2479,37 @@ fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() { assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה"); } +/// #2013: the dialog asks `first_undrawable` before offering OK, so it must refuse exactly what +/// applying refuses: a Cyrillic footer was previewed (in the interface font) and then failed. +#[test] +fn header_and_watermark_drawability_matches_what_applying_refuses() { + let footer = |t: &str| HeaderFooter { + text: [String::new(), String::new(), String::new(), String::new(), String::new(), t.into()], + ..HeaderFooter::default() + }; + for (text, refused) in [ + ("Страница <<1>> из 1 / Page <<1>> of 1", Some('С')), + ("TEST-001 <>", None), + ("Café — 5€ ™ <>", None), + ("Left\r\nRight", None), + ("A\rB", Some('\r')), + ("line one\nΕλληνικά", Some('Ε')), + ] { + let hf = footer(text); + assert_eq!(hf.first_undrawable(), refused, "{text:?}"); + let mut doc = fixture(); + let applied = add_header_footer(&mut doc, &[0], &hf, false, &cx()); + match refused { + Some(c) => assert_eq!(applied.unwrap_err().to_string(), undrawable_message(c), "{text:?}"), + None => applied.unwrap(), + } + let wm = Watermark { text: text.into(), ..Watermark::default() }; + assert_eq!(wm.first_undrawable(), refused, "{text:?}"); + let applied = add_watermark(&mut fixture(), &[0], &wm, false); + assert_eq!(applied.err().map(|e| e.to_string()), refused.map(undrawable_message), "{text:?}"); + } +} + /// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`, /// and leave the document untouched. #[test] @@ -3236,6 +3480,27 @@ fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen( assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3); } +/// Two edits applied together (heading and footer edited before leaving the editor, #2198): the +/// runs between them are unchanged and keep their own styles instead of taking the heading's. +#[test] +fn unchanged_runs_between_two_edits_keep_their_styles() { + let original = mixed_region_page(); + let look = + |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.trim().to_string(), l.base_font, l.color)).collect::>(); + let before = look(&original); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + let mut doc = original.clone(); + let draft = block.editor_text.replace("About", "About us").replace("footer", "footnote"); + text::replace_block(&mut doc, 0, 1, &draft).unwrap(); + let after = look(&reopen(&doc)); + let style = |lines: &[(String, String, [f64; 3])], word: &str| lines.iter().find(|l| l.0 == word).map(|l| (l.1.clone(), l.2)); + for word in ["Plain", "bold", "text."] { + assert_eq!(style(&after, word), style(&before, word), "{word:?} keeps its own style; lines after the edit: {after:?}"); + } + assert_eq!(style(&after, "About us"), style(&before, "About")); + assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer")); +} + #[test] fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() { let mut doc = mixed_region_page(); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 1982463174..802e0a846a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -270,17 +270,17 @@ pub struct TextBlock { } impl TextBlock { - /// Preserve styles outside the changed range; inserted characters inherit the surrounding - /// source run. Byte ranges are always UTF-8 boundaries. Linear time, including large edits. + /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding + /// source run. The texts are aligned word by word, so words left untouched between two edits + /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. + /// Byte ranges are always UTF-8 boundaries. Linear time, including large edits. pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range, usize)> { if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 { return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))]; } let old: Vec<_> = self.editor_text.chars().collect(); let new: Vec<_> = draft.char_indices().collect(); - let prefix = old.iter().zip(&new).take_while(|(a, (_, b))| *a == b).count(); - let suffix = - old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, (_, b))| *a == b).count(); + let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect(); let mut original = Vec::with_capacity(old.len()); let mut span = 0; for (byte, _) in self.editor_text.char_indices() { @@ -289,17 +289,36 @@ impl TextBlock { } original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); } - let inherited = original.get(prefix.saturating_sub(1)).or_else(|| original.get(prefix)).copied().unwrap_or(0); + let mut sources = Vec::with_capacity(new.len()); + let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::>(); + let (old_words, new_words) = (slices(&old), slices(&chars)); + match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) { + Some(script) => { + // `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends. + let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0)); + let (mut a, mut b) = (old_words.iter(), new_words.iter()); + for step in script { + match step { + Step::Keep => { + replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); + let len = a.next().map_or(0, Vec::len); + b.next(); + sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default()); + (x, y) = (x.saturating_add(len), y.saturating_add(len)); + (o, n) = (x, y); + } + Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)), + Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)), + } + } + replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources); + } + None => replaced(&original, 0, &old, &chars, &mut sources), + } + let fallback = original.first().copied().unwrap_or(0); let mut result: Vec<(std::ops::Range, usize)> = Vec::new(); for (i, (byte, ch)) in new.iter().enumerate() { - let source = if i < prefix { - original.get(i).copied() - } else if i >= new.len().saturating_sub(suffix) { - original.get(old.len().saturating_sub(new.len() - i)).copied() - } else { - Some(inherited) - } - .unwrap_or(inherited); + let source = sources.get(i).copied().unwrap_or(fallback); let end = byte.saturating_add(ch.len_utf8()); if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) { range.end = end; @@ -311,6 +330,108 @@ impl TextBlock { } } +/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change. +/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear. +const MAX_CHANGED_WORDS: usize = 64; + +/// `text` split into words, each with the whitespace after it, as char ranges. +fn words(text: &[char]) -> Vec> { + let mut out = Vec::new(); + let mut start = 0; + for (i, pair) in text.windows(2).enumerate() { + if let [a, b] = pair + && a.is_whitespace() + && !b.is_whitespace() + { + out.push(start..i + 1); + start = i + 1; + } + } + if start < text.len() { + out.push(start..text.len()); + } + out +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Step { + Keep, + Delete, + Insert, +} + +/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps. +fn edit_script(a: &[T], b: &[T], max: usize) -> Option> { + let (n, m) = (a.len() as isize, b.len() as isize); + let max = max.min(a.len().saturating_add(b.len())) as isize; + // Furthest x reached on each diagonal k = x - y, stored at k + max + 1. + let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); + let mut v = vec![0isize; (2 * max + 3) as usize]; + let mut trace = Vec::new(); + for d in 0..=max { + trace.push(v.clone()); + for k in (-d..=d).step_by(2) { + let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1)); + let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 }; + let mut y = x - k; + while x < n && y < m && a.get(x as usize) == b.get(y as usize) { + x += 1; + y += 1; + } + if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) { + *slot = x; + } + if x >= n && y >= m { + return Some(backtrack(&trace, n, m, max)); + } + } + } + None +} + +/// Walk Myers' saved frontiers back from the end to the edit script. +fn backtrack(trace: &[Vec], n: isize, m: isize, max: isize) -> Vec { + let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0); + let mut steps = Vec::new(); + let (mut x, mut y) = (n, m); + for (d, v) in trace.iter().enumerate().rev() { + let (d, k) = (d as isize, x - y); + let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 }; + let prev_x = at(v, prev_k); + let prev_y = prev_x - prev_k; + while x > prev_x && y > prev_y { + steps.push(Step::Keep); + x -= 1; + y -= 1; + } + if d > 0 { + steps.push(if x == prev_x { Step::Insert } else { Step::Delete }); + } + (x, y) = (prev_x, prev_y); + } + steps.reverse(); + steps +} + +/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their +/// runs, and the rest inherit the run just before the change (or the first one it replaces). +fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec) { + let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count(); + let start = at.saturating_add(prefix); + let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0); + for i in 0..new.len() { + let source = if i < prefix { + original.get(at.saturating_add(i)).copied() + } else if i >= new.len().saturating_sub(suffix) { + original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied() + } else { + Some(inherited) + }; + out.push(source.unwrap_or(inherited)); + } +} + impl TextLine { /// The baseline's height in user space. pub fn origin_baseline(&self) -> f64 { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index afc0673be9..d467ca2cba 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -129,6 +129,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Modify page", items: &[ + item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready), item("Rotate pages", "rotate-cw", "page.rotate", Ready), item("Insert pages", "file-plus-2", "page.insert", Ready), item("Delete pages", "trash-2", "page.delete", Ready), @@ -139,7 +140,6 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ ToolSection { title: "Add content", items: &[ - item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready), item("Text", "type", "edit.text", Ready), item("Image", "image-plus", "edit.image", Ready), menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 228bdd0ffe..414cf7553f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -222,6 +222,9 @@ pub const COMMANDS: &[CommandSpec] = &[ // also run from Combine files, which has no document open. c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"), c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"), + // View-local history also appears in the Page navigation section; don't duplicate its menu. + c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"), + c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"), c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"), diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index c3f9a50861..8c14a5b1b3 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -669,6 +669,26 @@ mod tests { assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one"); } + /// 21:30 on 14 Oct 2026 in São Paulo (UTC-3) is 00:30Z on the 15th. The date Fill & Sign + /// stamps is the wall-clock day (14th); the time the file records (`/M`, `/ModDate`) is UTC. + /// The test shortcut (`with_clock`) takes the injected time as local and says the 15th, which + /// is why it cannot serve a host that knows both the UTC time and the zone. + #[test] + fn a_wall_clock_gives_the_local_day_and_keeps_utc_for_the_file() { + let sao_paulo = Session::new().with_wall_clock(|| 1_792_024_200, || -3 * 3600); + assert_eq!(sao_paulo.today(), (2026, 10, 14)); + assert_eq!(sao_paulo.today_text(None, None).as_deref(), Ok("10/14/2026")); + assert_eq!(sao_paulo.signing_date(), "D:20261014213000-03'00'"); + assert_eq!(sao_paulo.stamp_by_line("Ada"), "By Ada at 9:30 pm, Oct 14, 2026"); + assert_eq!(sao_paulo.now_secs(), 1_792_024_200, "the file's instant stays in UTC"); + + let utc = Session::new().with_wall_clock(|| 1_792_024_200, || 0); + assert_eq!(utc.today(), (2026, 10, 15)); + + let clock_only = Session::new().with_clock(|| 1_792_024_200); + assert_eq!(clock_only.today(), (2026, 10, 15), "on its own, an injected clock counts as local time"); + } + #[test] fn names_follow_the_date_language() { assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026"); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index a6af0cdf8e..579a483efc 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -46,8 +46,11 @@ pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Res } /// The file name for the `index`-th (1-based) exported image: `_Page__Image_.`. -pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize) -> String { - format!("{stem}_Page_{}_Image_{index:04}.{}", image.page + 1, image.extension) +/// The page number has leading zeros for as many digits as the document's `page_count` has, so the +/// files sort in page order (#2208): page 7 of 120 is `Page_007`. +pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize, page_count: usize) -> String { + let width = page_count.max(1).to_string().len(); + format!("{stem}_Page_{:0width$}_Image_{index:04}.{}", image.page + 1, image.extension) } impl Exporter { @@ -212,3 +215,29 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result pdfcraft_create::ExtractedImage { + pdfcraft_create::ExtractedImage { + page, + object: pdfcraft_cos::ObjRef { num: 1, generation: 0 }, + width: 1, + height: 1, + extension: "png", + data: Vec::new(), + } + } + + #[test] + fn page_number_is_padded_to_the_page_count() { + assert_eq!(image_file_name("doc", &image(0), 1, 1), "doc_Page_1_Image_0001.png"); + assert_eq!(image_file_name("doc", &image(0), 1, 9), "doc_Page_1_Image_0001.png"); + assert_eq!(image_file_name("doc", &image(6), 2, 20), "doc_Page_07_Image_0002.png"); + assert_eq!(image_file_name("doc", &image(6), 3, 120), "doc_Page_007_Image_0003.png"); + assert_eq!(image_file_name("doc", &image(119), 4, 120), "doc_Page_120_Image_0004.png"); + assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e1654e781f..cfbec3b7ff 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,6 +28,7 @@ pub mod js; pub mod links; pub mod ocr; pub mod optimizer; +pub mod print_raster; pub mod printcheck; mod scan_colours; mod scan_fields; @@ -43,7 +44,7 @@ pub use signature_image::{ImageSignaturePreview, SignatureImage}; pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn}; pub use text_preview::TextEditPreview; -pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges}; +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges}; /// One file produced by a split: (1-based first page, last page, PDF bytes). pub use pdfcraft_organize::LabelStyle; @@ -57,7 +58,7 @@ pub use pdfcraft_edit::{ pub use pdfcraft_forms::{ BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, - flags as field_flags, + appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags, }; pub use pdfcraft_a11y as a11y; @@ -77,6 +78,8 @@ pub enum ImageEdit { Flip { horizontal: bool, }, + /// Show only this box of it (user space), the pixels staying where they are. + Crop([f64; 4]), /// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place. Replace { name: String, @@ -84,7 +87,7 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline}; +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. @@ -229,6 +232,7 @@ fn scope_of(edit: &Edit) -> Scope { | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Scope::Comments, Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, // Page content, resources and added content/marks only: the page tree, annotations, @@ -795,6 +799,23 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc>, password: Option<&str>) -> Arc> { + guard(|| { + pdfcraft_cos::Document::open_with_password(bytes.clone(), password) + .ok() + .and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + /// Fails when the renderer still draws text under an applied redaction mark. /// /// Redaction decides what lies under a mark from the positions its own interpreter computes, and @@ -920,6 +941,8 @@ pub struct BookmarkRow { pub open: bool, /// The 0-based page it goes to. pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, } /// One page of the bookmark list, in outline order. @@ -947,7 +970,7 @@ fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { top.1 += 1; } let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page }); + rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view }); if !item.children.is_empty() { levels.push((&item.children, 0)); } @@ -1151,6 +1174,12 @@ pub enum Edit { which: PageBox, spec: BoxSpec, }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, SetInfo { key: String, value: String, @@ -1300,6 +1329,12 @@ pub enum Edit { index: usize, fill: Option, }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { page: usize, @@ -1551,6 +1586,11 @@ pub enum Edit { }, /// Sanitize Document: every category, then a full rewrite on save. Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and + /// PdfCraft's private keys go, the pages as laid out and the fields with their values + /// stay, so every viewer shows the same form and none lays it out again. Its scripts no + /// longer run. + XfaToStatic, /// Flatten comments and/or form fields on every page into page content. Flatten { comments: bool, @@ -1647,6 +1687,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Crop page", "Crop pages", "Set page boxes", + "Set page transitions", "Change Title", "Change Author", "Change Subject", @@ -1782,6 +1823,7 @@ impl Edit { Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), Edit::SetPageBox { .. } => Label::of("Set page boxes"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can // be said in another language; any other key is the document's and goes in as it stands. Edit::SetInfo { key, .. } => match key.as_str() { @@ -1827,7 +1869,9 @@ impl Edit { Edit::LockAnnotation { .. } => Label::of("Unlock comment"), Edit::MoveAnnotation { .. } => Label::of("Move comment"), Edit::ResizeAnnotation { .. } => Label::of("Resize comment"), - Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"), + Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => { + Label::of("Change comment properties") + } Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), Edit::ResetForm { .. } => Label::of("Clear form"), @@ -1857,6 +1901,7 @@ impl Edit { Edit::EditPageImage { change, .. } => Label::of(match change { ImageEdit::Move(_) => "Move image", ImageEdit::Rotate(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", @@ -1885,6 +1930,7 @@ impl Edit { Edit::RemoveLinks { .. } => Label::of("Remove all links"), Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), Edit::Flatten { .. } => Label::of("Flatten"), @@ -1947,6 +1993,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } | Edit::InsertPagesFrom { .. } // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). | Edit::AddBookmark { .. } @@ -1977,6 +2024,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::ResizeAnnotation { .. } | Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } | Edit::SetMeasurementScale { .. } => { if p.annotate() { @@ -2021,6 +2069,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } | Edit::Sanitize + | Edit::XfaToStatic | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } | Edit::DuplicateField { .. } @@ -2065,6 +2114,8 @@ struct EditCtx { xfa_datasets: Option, /// A form script ran too long and was abandoned: the document's scripts go off. xfa_ran_away: bool, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. pages: usize, date: Option, @@ -2094,6 +2145,7 @@ impl EditCtx { xfa_out: Default::default(), xfa_datasets: None, xfa_ran_away: false, + xfa_removed: Vec::new(), pages: 0, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), @@ -2156,13 +2208,16 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; } Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { let src = open_source(name, bytes)?; let pages = match pages { Some(p) => p.clone(), None => (0..pdfcraft_organize::page_count(&src)?).collect(), }; - let outcome = pdfcraft_organize::import_pages_with(doc, &src, &pages, *at, *field_conflicts)?; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?; cx.renamed_fields = outcome.renamed_fields; } Edit::AddBookmark { parent, index, title, page } => { @@ -2231,6 +2286,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; } @@ -2357,6 +2413,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> } pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; @@ -2474,8 +2531,26 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_redact::sanitize::sanitize(doc)?; } Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); let n = pdfcraft_model::pages(doc).len(); pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; } Edit::FlattenFillSign => { let n = pdfcraft_model::pages(doc).len(); @@ -2706,6 +2781,11 @@ pub struct Session { next_id: u64, /// Seconds since the Unix epoch, injected so saves are deterministic in tests. clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, /// Certificates trusted for signing (Acrobat: Trusted Certificates). trust: Arc, /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). @@ -2861,6 +2941,62 @@ fn decode_data_file(bytes: &[u8]) -> Option> { } } +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + /// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), /// rather than XFDF, Acrobat's `` XML or a table? By its root element. fn is_xfa_data_file(text: &str) -> bool { @@ -2988,6 +3124,28 @@ impl Session { self } + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + /// Seconds since the Unix epoch from the session clock (0 when unknown). pub fn now_secs(&self) -> i64 { self.now().unwrap_or(0) @@ -3007,7 +3165,7 @@ impl Session { /// is taken as local time. /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). pub fn stamp_by_line(&self, author: &str) -> String { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); @@ -3018,7 +3176,7 @@ impl Session { } pub fn today(&self) -> (i64, u32, u32) { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) } @@ -3490,6 +3648,12 @@ impl Session { doc.dirty = true; doc.generation += 1; doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } note_warnings(&mut doc.xfa_warnings, &xfa_notes); let notes = doc.xfa_warnings.clone(); note_warnings(&mut doc.info.warnings, ¬es); @@ -3658,13 +3822,8 @@ impl Session { /// appearances are kept: content edits leave every annotation dictionary alone. fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { Self::release_working(doc); + sync_xfa_state(doc); let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // A script may have laid an XFA form out again (rows added, subforms shown). - if doc.xfa.is_some() - && let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos) - { - doc.xfa = Some(report); - } let bytes = if editor.cos.is_modified() { Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) } else { @@ -3720,13 +3879,8 @@ impl Session { editor.appearances.clear(); } Self::release_working(doc); + sync_xfa_state(doc); let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // A script may have laid an XFA form out again (rows added, subforms shown). - if doc.xfa.is_some() - && let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos) - { - doc.xfa = Some(report); - } let bytes = if editor.cos.is_modified() { Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) } else { @@ -4022,6 +4176,26 @@ impl Session { if !doc.allows_printing() { return Err(EditError::NotPermitted("printing")); } + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = &settings.raster { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly); + let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } let cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { @@ -4224,7 +4398,7 @@ impl Session { let mut cuts = Vec::new(); let mut used = 0usize; for p in 0..n { - let size = self.write_new(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); if used > 0 && used + size > max_bytes { cuts.push(p); used = 0; @@ -4284,7 +4458,17 @@ impl Session { let doc = self.get(id)?; if let Some(editor) = doc.editor.as_ref() { let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page.bookmarks.into_iter().map(|b| BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page }).collect(); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view } + }) + .collect(); return Some(BookmarkPage { rows, next: page.next, truncated: false }); } let all = outline_rows(&doc.info.outline); @@ -4322,7 +4506,18 @@ impl Session { doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) } + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string())) } @@ -4441,7 +4636,7 @@ impl Session { /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). pub fn signing_date(&self) -> String { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); let stamp = local.trim_end_matches('Z'); if offset == 0 { @@ -4478,7 +4673,10 @@ impl Session { if opts.date.is_empty() { opts.date = self.signing_date(); } - Ok(Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?)) + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) } /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's @@ -4487,7 +4685,9 @@ impl Session { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; let date = if date.is_empty() { self.signing_date() } else { date }; - Ok(Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?)) + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) } /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature @@ -4495,7 +4695,9 @@ impl Session { pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { let d = self.get(doc).ok_or(EditError::NoDocument)?; let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - Ok(Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?)) + let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) } /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], diff --git a/crates/engine/src/ocr.rs b/crates/engine/src/ocr.rs index 6bf6dc9c75..dcd4142d75 100644 --- a/crates/engine/src/ocr.rs +++ b/crates/engine/src/ocr.rs @@ -30,6 +30,10 @@ pub struct OcrSettings { /// text read again, not a word in a picture. const ALREADY_ON_PAGE: f32 = 0.5; +/// The long side of an A4 sheet in points (a letter page's, 792, is shorter): "Downsample to" +/// is a resolution on paper this size or smaller. +const SHEET: f32 = 842.0; + /// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page /// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the /// sum is capped at the whole word. @@ -121,11 +125,7 @@ impl OcrJob { out.push(skip("the page already contains text")); continue; } - // A scan is read at its own resolution (enlarging it only blurs the letters), at most - // the chosen one. - let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; - let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted)); - let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + let shot = r.render(RenderRequest { page, scale: self.scale(page, info), ..Default::default() }); if let Some(e) = shot.error { out.push(skip(&e)); continue; @@ -151,6 +151,18 @@ impl OcrJob { progress(total, total); out } + + /// The render scale (pixels per point) `page` is read at. A scan is read at its own + /// resolution (enlarging it only blurs the letters), at most the chosen one. + fn scale(&self, page: usize, info: &PageInfo) -> f32 { + let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; + let Some(native) = self.native.get(page).copied().flatten() else { return wanted }; + // A scan whose page is larger than a sheet (often a box given in pixels, one point per + // pixel, #1848) is measured as if shrunk to one: otherwise 1 px/pt is already its full + // image, and no "Downsample to" choice could ask for less. + let fit = (SHEET / info.width.max(info.height).max(1e-3)).min(1.0); + native.clamp(fit, wanted * fit) + } } impl Session { @@ -236,12 +248,16 @@ pub fn recognize_file( if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 { return Ok(FileResult { bytes, pages }); } - Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages }) + // Checked like a save before the caller writes it (#2166). + let bytes = s.save_bytes(id).map_err(|e| e.to_string())?; + s.check_save(id, &bytes).map_err(|e| e.to_string())?; + Ok(FileResult { bytes, pages }) } #[cfg(test)] mod tests { use super::*; + use crate::export; /// #1028: a recognised word over the page's own text is that text again; one in an empty /// part of the page (a picture) is new. @@ -260,6 +276,28 @@ mod tests { assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0); } + /// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel + /// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution + /// letter page is still never shrunk below one pixel per point. + #[test] + fn downsample_shrinks_a_page_box_given_in_pixels() { + let mut s = Session::new(); + let page = |w: u32, h: u32| export::encode_png(w, h, &vec![255; (w * h * 4) as usize]).unwrap(); + let bytes = s.create_from_images(&[("a4.png".into(), page(2480, 3508)), ("letter.png".into(), page(612, 792))]).unwrap(); + let id = s.open("scan.pdf", None, bytes, None).unwrap(); + let mut r = PageRenderer::new(s.get(id).unwrap().bytes.clone(), RenderConfig::default()); + for (dpi, a4) in [(72.0, [595, 842]), (150.0, [1240, 1754]), (300.0, [2480, 3508]), (600.0, [2480, 3508])] { + let job = s.ocr_job(id, &[], OcrSettings { dpi, ..Default::default() }).unwrap(); + for (page, want) in [(0, a4), (1, [612, 792])] { + let scale = job.scale(page, &job.infos[page]); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + assert!(shot.error.is_none(), "{:?}", shot.error); + let near = |got: u32, want: u32| got.abs_diff(want) <= 1; + assert!(near(shot.width, want[0]) && near(shot.height, want[1]), "{dpi} dpi, page {page}: {}x{}", shot.width, shot.height); + } + } + } + #[test] fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() { // A glyph drawn twice (fake bold) does not cover more than the whole word. diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs new file mode 100644 index 0000000000..117c8e14cc --- /dev/null +++ b/crates/engine/src/print_raster.rs @@ -0,0 +1,61 @@ +//! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page +//! sent to the printer as a picture at a chosen resolution, for printers that skip text, +//! shading or transparency. The pages are rendered the way the viewer shows them (reading +//! colours off, the print's own colours), made into an image-only document, and that document +//! is imposed on the paper like any other. The print is no longer searchable text. + +use pdfcraft_create::ImageResolution; +use pdfcraft_print::Raster; +use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest}; + +use crate::Document; +use crate::export::encode_png; + +/// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one +/// page per entry, the size of the source page as displayed. Pages too large for the renderer +/// at `raster.dpi` render at the largest resolution it allows. +pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> { + let dpi = raster.dpi.clamp(72.0, 600.0); + let src = doc.export_source(); + let mut config = src.config.clone(); + config.hide_comments = hide_comments; + let mut renderer = PageRenderer::new(src.bytes, config); + let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len()); + let mut used: Option = None; + for &page in pages { + let (w, h) = src.sizes.get(page).copied().ok_or_else(|| format!("page {} does not exist", page + 1))?; + let scale = pdfcraft_render::effective_scale(w, h, (dpi / 72.0) as f32); + let page_dpi = f64::from(scale) * 72.0; + // Every page of the image document shares one resolution, so its pages keep the + // source's sizes; the lowest a page needed is used for all. + used = Some(used.map_or(page_dpi, |u: f64| u.min(page_dpi))); + let r = renderer.render(RenderRequest { page, scale, tag: PRINT_TAG, ..Default::default() }); + if let Some(e) = r.error { + return Err(format!("page {}: {e}", page + 1)); + } + let mut rgba = r.rgba.to_vec(); + over_white(&mut rgba, raster.grayscale); + images.push((format!("page-{}.png", page + 1), encode_png(r.width, r.height, &rgba)?)); + } + let dpi = used.unwrap_or(dpi); + let doc = pdfcraft_create::from_images_with_resolution(&images, ImageResolution::Dpi(dpi)).map_err(|e| e.to_string())?; + pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string()) +} + +/// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma). +fn over_white(rgba: &mut [u8], grayscale: bool) { + for px in rgba.as_chunks_mut::<4>().0 { + let gap = 255 - u32::from(px[3]); + let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255)); + let (r, g, b) = if grayscale { + let y = ((299 * r + 587 * g + 114 * b) / 1000).min(255); + (y, y, y) + } else { + (r, g, b) + }; + px[0] = r as u8; + px[1] = g as u8; + px[2] = b as u8; + px[3] = 255; + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2711d19f77..60a0aa0f8f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1811,6 +1811,34 @@ fn form_refresh_keeps_first_value_when_field_names_repeat() { assert_eq!(dup.as_deref(), Some("first")); } +#[test] +fn multiselect_choice_indices_stay_sorted_through_engine_save_and_reopen() { + let (mut s, id) = session_with(1); + s.apply( + id, + Edit::AddField { + page: 0, + rect: [20.0, 180.0, 180.0, 240.0], + kind: NewField::List { options: vec!["Cheese".into(), "Ham".into(), "Olives".into()], multi: true }, + name: Some("toppings".into()), + }, + ) + .unwrap(); + let chosen = vec!["Olives".to_string(), "Ham".to_string()]; + s.apply(id, Edit::SetFieldValue { name: "toppings".into(), value: FieldValue::Choice(chosen.clone()) }).unwrap(); + assert_eq!(s.get(id).unwrap().form[0].value, chosen, "the live engine keeps selection order"); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let all = pdfcraft_forms::fields(&cos); + let list = all.iter().find(|field| field.name == "toppings").unwrap(); + let object = cos.get(list.obj); + let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap(); + assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::>(), [1, 2]); + assert_eq!(list.value, chosen, "sorted /I does not reorder /V"); + let reopened = s.open("reopened.pdf", None, saved, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form[0].value, chosen, "reopening keeps selection order"); +} + #[test] fn preparing_a_form_adds_renames_and_deletes_fields() { let (mut s, id) = session_with(1); @@ -3918,6 +3946,113 @@ fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() { ); } +#[test] +fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() { + let data = "
JPEnglish\nSpanish
"; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fields.pdf", None, bytes, None).expect("opens"); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap(); + s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap(); + let d = s.get(id).unwrap(); + assert!(d.form.is_empty(), "no fields remain"); + assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages"); + assert!(d.xfa.is_none()); + let texts = page_texts(&s, id).join("\n"); + for want in ["Japan", "typed here", "Country: JP", "12345"] { + assert!(texts.contains(want), "{want} is page content now: {texts}"); + } + // Saved and reopened: a plain PDF, the same page content, nothing of the XFA left. + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none()); + let id2 = s.open("flat.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + assert_eq!((d2.info.xfa, d2.form.len()), (None, 0)); + assert!(page_texts(&s, id2).join("\n").contains("typed here")); + // Undone, the form is a laid-out XFA form again. + s.undo(id).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); + assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]); + // Flattening comments alone leaves the XFA form as it is. + s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap(); + assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic)); +} + +#[test] +fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() { + // A template that can't be read: the form keeps its placeholder page and its XFA. + let broken = "", "
3kept nowherex
")))).unwrap(); + let before = crate::xfa_shape(&doc).unwrap(); + assert_eq!(before, crate::XfaShape { needs_rendering: true, has_fields: false, laid_out: false }); + assert!(!before.strippable(), "nothing laid out yet: stripping would leave the placeholder page"); + render_into(&mut doc).unwrap(); + assert!(crate::xfa_shape(&doc).unwrap().strippable()); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.unbound_data, Some(2), "{r:?}"); + assert_eq!(r.script_buttons, 4, "addRow, removeRow, hello, legacy: {r:?}"); + assert!(crate::xfa_shape(&doc).is_none()); + // A field tree whose dictionaries share one Kids array (every kid listing every other) is + // walked once per dictionary, not once per listing. + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let root = doc.root().unwrap(); + let acro_ref = doc.get(root).as_dict().unwrap().get(b"AcroForm").and_then(Object::as_ref); + let fields: Vec = (0..2000).map(|_| Object::Ref(doc.add(Object::Dict(pdfcraft_cos::Dict::new())))).collect(); + let kids = doc.add(Object::Array(fields.clone())); + for f in &fields { + if let Some(r) = f.as_ref() { + doc.update_dict(r, |d| { + d.set(b"Kids".to_vec(), Object::Ref(kids)); + d.set(b"PCSom".to_vec(), Object::String(pdfcraft_cos::PdfString::text("x"))); + }) + .unwrap(); + } + } + let update = |d: &mut pdfcraft_cos::Dict| d.set(b"Fields".to_vec(), Object::Array(fields.clone())); + match acro_ref { + Some(r) => doc.update_dict(r, update).unwrap(), + None => doc + .update_dict(root, |c| { + let mut a = c.get(b"AcroForm").and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + update(&mut a); + c.set(b"AcroForm".to_vec(), Object::Dict(a)); + }) + .unwrap(), + } + let started = std::time::Instant::now(); + let r = strip(&mut doc).unwrap(); + assert_eq!(r.field_keys, 2000); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); +} + +#[test] +fn som_indices_count_earlier_same_named_siblings() { + let draw = |name: Option<&str>| { + Node::Draw(Box::new(Draw { common: Common { name: name.map(str::to_string), ..Default::default() }, ..Default::default() })) + }; + let children = [draw(Some("a")), draw(Some("b")), draw(Some("a")), draw(None), draw(Some("a")), draw(Some("b"))]; + let sibs = SomIndices::of(&children); + assert_eq!((0..=children.len()).map(|i| sibs.at(i)).collect::>(), [0, 0, 1, 0, 2, 1, 0]); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9f016bf0de..9717c14e04 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,8 @@ # Where PdfCraft falls short of Acrobat Pro > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1317, #1327, #1302, #1315 and #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (#1316: replacing a line keeps a neighbouring line whose glyph box overlaps; gap 3 remains) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** gap 4 closed: the web build prints through the browser's print dialog; exclusive image-crop resize follow-up; minor (Word invoice regression coverage, #773; Office fidelity still partial; gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -135,7 +137,8 @@ update the numbers. batch keeps a cloudy comment flatten cannot draw (#1317), leaves a refused text-box restyle or resize unchanged (#1327), keeps arrow defaults off plain lines (#1302), stops a custom text match on the separator instead of the next glyph (#1315), and states the vertical-text - redaction refusal without claiming an overlap (#1340). The rest of the backlog is untriaged. + redaction refusal without claiming an overlap (#1340). Organize Pages opens on the page being + read, selected and scrolled into view (#1910). The rest of the backlog is untriaged. - **Impact:** every one is a user who lost trust. Crash reports outrank feature work (`AGENTS.md` §4). - **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file. @@ -361,6 +364,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | | 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. | | 2026-10-12 | minor | #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps. Gap 3 remains. Estimates unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d4a9a166c4..995293af3d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,7 @@ # Localization parity > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Vietnamese 100% of current key union; native-speaker community review; twelve-language mean ≈68%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Italian count after completing its catalog) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -62,7 +63,7 @@ none has translated help. Vietnamese's **To `full`** hours are therefore residua | Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,130 (99.4%) | partial | yes | +| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes | | Kazakh | `kk` | 2,327 entries | partial | no | | Polish | `pl` | 2,287 entries | partial | yes | | Slovenian | `sl` | 2,324 entries | partial | no | @@ -73,11 +74,15 @@ none has translated help. Vietnamese's **To `full`** hours are therefore residua **14 other languages** ship (22 catalogs plus English = **23** interface languages in Preferences). The entry counts without percentages were measured on 2026-10-12–13; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. +**14 other languages** ship (21 catalogs plus English, 22 interface languages in Preferences). +The entry counts without percentages were measured on 2026-10-12 (Italian on 2026-10-14); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +Acrobat languages PdfCraft lacks: Danish, Dutch, Finnish, Norwegian, Swedish, Hebrew. ## Revision history | Date | Change | Summary | |---|---|---| | 2026-10-13 | minor | Vietnamese (`vi`) catalog: 2,591 entries (100% of current key union + `theme`/`Appearance`); twelve-language mean ≈ 68%; native-speaker community review; registered with `plural_none` | +| 2026-10-14 | minor | Italian: 2,528 entries after adding the 201 keys the other catalogs had and the code looks up | | 2026-10-12 | minor | Added current counts for Kazakh, Polish, Slovenian, Greek, Turkish and Lithuanian; retained existing language assessments and the original percentage snapshot | | 2026-10-10 | major | Created. Catalog counts measured from `crates/ui-egui/src/i18n/*.tsv` against the 2,143-key union; Acrobat's localizations from its bundle's `.lproj` and Sequences folder names | diff --git a/docs/localization.md b/docs/localization.md index 89ef2d74d6..657e2eb247 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -15,6 +15,8 @@ The control channel exposes the setting through `ui.set`: The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `pl`, `sl`, `el`, `tr`, `lt` or `vi`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese and others) cover the current localizable surface (about 2,000–2,600 entries each); Czech still covers mainly the menus. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each), and Italian has 2,528 entries; Czech and Turkish cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each), and Italian has 2,528 entries; Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). @@ -24,7 +26,7 @@ The Lithuanian catalog has 2,585 entries and three integer plural forms. Calenda The Vietnamese catalog has 2,591 entries and one plural form (`plural_none`). Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters. +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Turkish and Lithuanian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. @@ -63,7 +65,7 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`): - Each language has a catalog, `crates/ui-egui/src/i18n/.tsv`, with one entry per line: `contextEnglishtranslation`. An empty context marks a plain string. `@id` keys an entry by command id (for example `file.saveAs`), so one menu item can read differently from another with the same English label. `@plural` holds plural forms. Any other context disambiguates an English word with several meanings (`tl_ctx!` in UI code, for example the signature pad's "Type" button against the "Type" column). The header of `ja.tsv` documents the format and escapes. - Placeholders (`{name}`) must appear in both columns. A trailing `…` must be kept. `i18n::fmt` fills placeholders in one pass, so a value such as a file name containing `{n}` is inserted as written. - Catalogs are validated strictly: unknown escapes, unknown `@` contexts, placeholder or ellipsis mismatches, wrong plural form counts and duplicates are errors. The tests require the bundled catalogs to have none; at run time a bad line is skipped (and logged) and that string shows in English. -- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. +- Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. After editing a catalog, put the new hash into its entry by hand (`sha256sum crates/ui-egui/src/i18n/.tsv`), because `cargo xtask assets --write` does not touch `ATTRIBUTION.toml`; the hash check is deliberate, so a changed asset gets its provenance re-checked. Without it `cargo xtask assets` and the test `assets::tests::the_real_manifest_passes` fail. - Translations are clean-room: write them from the meaning of the English text, never from another product's string tables. ## Sentences the engine words @@ -82,6 +84,6 @@ Error details that come from the operating system, and faults rather than mistak 1. Copy the header of `ja.tsv` into `xx.tsv` and translate entries. 2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. -3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), then run `cargo xtask assets --write` and `cargo xtask assets`. +3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), with the catalog's `sha256sum` as `sha256`, then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match and the catalog tests (format, duplicates, placeholders, plural forms, command ids) then pick it up. diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index 23f2b66a3e..4e21ef2ec9 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§7.6.4: written encrypted files open in Acrobat, #774) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (§12.4.4: page transitions and display times read) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -35,7 +35,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | §10 | Rendering: transfer functions, halftones, overprint, smoothness | full | borrowed; no overprint preview | `print.overprint-preview` planned | | §11 | Transparency: blend modes, soft masks, groups, knockout | full | borrowed (knockout shipped) | `view.render-transparency` partial | | §12.1–12.3 | Viewer preferences, page layout, page labels, outlines, destinations | full | **own**: initial view, page labels, bookmarks edit and from structure, named destinations rewired on copy | named-destination editing panel planned | -| §12.4 | Page transitions, thumbnails, articles, presentations | full | none (thumbnails discarded or kept) | `organize.page-transitions`, `view.articles-panel` planned | +| §12.4 | Page transitions, thumbnails, articles, presentations | full | `/Trans` and `/Dur` read and listed by `doc_info`, not played; thumbnails discarded or kept | `organize.page-transitions` partial, `view.articles-panel` planned | | §12.5 | Annotations | 28 types | see table below | | | §12.6 | Actions | 19 types | see table below | | | §12.7 | Interactive forms (AcroForm): fields, appearances, calculation order, JavaScript actions | full | **own**: all field types, regenerated appearances, Acrobat's AF functions and event order, sandboxed JavaScript (boa) | `form.js-host` partial (object-model subset); submit planned | @@ -130,5 +130,6 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | §12.4.4: `/Trans` and `/Dur` read into `DocInfo::transitions` and reported by `doc_info` | | 2026-10-11 | minor | §7.6.4: encrypted files we write open in Acrobat (#774, fixed by #836) | | 2026-10-10 | major | Created from the code, `vendor/README.md` and `parity/acrobat-features.toml`, against ISO 32000-2 and Acrobat Pro 26.002.21931's documented capabilities | diff --git a/docs/releasing.md b/docs/releasing.md index 5d6a850ab6..ec2e5853b3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -121,10 +121,14 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. The welcome dialog has a "Create a desktop shortcut" checkbox and an "Edit with PdfCraft" right-click menu checkbox, both ticked by default. `/qn` and `/qb` stay unattended and create - both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed. An update (the - launcher runs `msiexec /passive` with no properties) keeps the desktop shortcut only if - `PdfCraft.lnk` is still on the all-users desktop, so deleting it once keeps it away (#1058); a - value given on the command line wins. + both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; + `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs + `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still + on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if + its key path is still in HKLM (#305). Both are AppSearch results read before the old version is + removed. An update from a release that didn't have a menu yet (before 0.7.1 for the PDF menu, + before 0.4.0 for the image menu, found by `OnlyDetect` Upgrade rows) adds it like a first install. + A value given on the command line wins. - The PDF right-click entry (#682) is a classic verb under `HKLM\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit`, so it shows whichever app is the default for `.pdf` and never changes that default. Several selected PDFs (up to 15, the @@ -132,8 +136,12 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI doesn't have. +- The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image + types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved + out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing + the new one (`afterInstallValidate`), which `test-msi.ps1` checks. - `test-msi.ps1 ` checks the compiled shortcut, context-menu and dialog tables, and - evaluates the desktop shortcut's first-install and update conditions. Packaging + evaluates the first-install and update conditions of the desktop shortcut and both menus. Packaging runs it in a child process before signing, so the MSI isn't held open when signtool runs. The ARM64 install smoke test checks that both all-users shortcuts point at the installed `pdfcraft.exe` and are removed on uninstall, and that `INSTALLDESKTOPSHORTCUT=0` skips the desktop one. diff --git a/docs/roadmap.md b/docs/roadmap.md index 22a24b81c7..3acd0ca8b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,12 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -91,19 +95,27 @@ total matches the parity estimate. | M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #2080 candidate: search-result jumps enter per-tab history; rail and control-channel Back/Forward; no exact-view or restart persistence. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Literal page-label prefix whitespace is preserved; nine public-inspection cases cover prefix spaces, encodings and empty-prefix controls | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Undo back to the opened or last saved file is clean; Revert opens that saved file after an undo; a later save appends to it so the saved revision stays in the file (#1310, #1311, #1312). Missing: page transitions, recovery on the web | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | -| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | +| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | @@ -119,9 +131,9 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | ## Upcoming milestones, ranked @@ -165,9 +177,12 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. | +| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged | | 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. | | 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. | | 2026-10-13 | minor | M2/M6: images with at most 16 bits of samples a pixel (8-bit Separation/DeviceN, 1-4-bit gray or indexed) convert once per pixel value (hayro-interpret patch 28), so spot-colour form backgrounds draw in milliseconds instead of seconds; widgets without `/P` are placed in one pass; the Fields panel no longer searches the form per row and draws only the rows in view. Estimates unchanged. | +| 2026-10-13 | minor | M3: native-open admission preserves modal/picker ownership; regressions cover queued replies and one-shot picker wakes. Estimates unchanged | | 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged | | 2026-10-12 | minor | M4: shared Type 3 glyph resources survive extraction, with save/reopen and independent raster regressions; estimates unchanged | | 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b5b93452e0..a78409fade 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -47,7 +47,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial | | Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped | | Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial | -| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures | shipped | +| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped | | Arrange, align and distribute page objects | yes | fields only | partial | | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index b1b8a14046..646eb61a36 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -46,9 +46,16 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) recommends: - cups-client - libwayland-client diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index 3f291721ab..7fae1329f3 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -6,9 +6,10 @@ Per-machine install into Program Files, a Start Menu shortcut, a desktop shortcut (on by default, optional, and an update keeps it only if it is still there), an App Paths entry, PdfCraft registered as a PDF handler (offered under "Open with" - and in Default apps, never forced as the default), and "Edit with PdfCraft" on PDF files' - right-click menu (on by default, optional). Files opened with PdfCraft arrive on its command - line. + and in Default apps, never forced as the default), "Edit with PdfCraft" on PDF files' + right-click menu and "Create PDF with PdfCraft…" on image files' right-click menu (both on by + default, optional, and an update keeps the installed version's choice). Files opened with + PdfCraft arrive on its command line. --> - - + + + + + + + + + + + + + + + @@ -98,38 +121,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 3af5bd0378..45e1147fa5 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -1,4 +1,4 @@ -<# +<# .SYNOPSIS Check the compiled MSI's install scope, publisher, shortcuts, native UI and OCR models, without installing it. .EXAMPLE @@ -88,33 +88,50 @@ foreach ($entry in @(@('StartMenuShortcut', 'ProgramMenuFolder', 'PdfcraftStartM Assert-Equal $component[0] $entry[1] "$($entry[2]) directory" Assert-Equal $component[1] $entry[3] "$($entry[2]) condition" } -# No static default (#1058): a SetProperty after AppSearch turns it on for a first install, and for an -# update only while the old shortcut is still on the desktop; a command-line value is left alone. -Assert-NoRow 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLDESKTOPSHORTCUT''' 'desktop shortcut static default' -$setDesktop = Read-Row 'SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''INSTALLDESKTOPSHORTCUT''' 2 -Assert-Equal $setDesktop[1] '1' 'Desktop shortcut default value' -foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { - $search = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''AppSearch''') 1 - $set = Read-Row ('SELECT `Sequence`, `Condition` FROM `' + $sequence + '` WHERE `Action` = ''' + $setDesktop[0] + '''') 2 - if ([int] $set[0] -le [int] $search[0]) { throw "$sequence must set the desktop shortcut default after AppSearch" } -} -$session = $null -try { - $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) - foreach ($case in @( - @('first install', '', '', '', 1), - @('first install, INSTALLDESKTOPSHORTCUT=0', '0', '', '', 0), - @('update, shortcut still there', '', '{00000000-0000-0000-0000-000000000000}', 'C:\Users\Public\Desktop\PdfCraft.lnk', 1), - @('update, shortcut deleted', '', '{00000000-0000-0000-0000-000000000000}', '', 0), - @('update, INSTALLDESKTOPSHORTCUT=0', '0', '{00000000-0000-0000-0000-000000000000}', 'C:\Users\Public\Desktop\PdfCraft.lnk', 0))) { - $session.Property('INSTALLDESKTOPSHORTCUT') = $case[1] - $session.Property('WIX_UPGRADE_DETECTED') = $case[2] - $session.Property('EXISTINGDESKTOPSHORTCUT') = $case[3] - Assert-Equal ($session.EvaluateCondition($set[1])) $case[4] "Desktop shortcut default: $($case[0])" +# Defaults an update keeps (#1058, #305). No static default: a SetProperty after AppSearch turns it on +# for a first install and, for an update from a release that offered it ($Release set), only while what +# it installed is still there ($Found, from AppSearch). A command-line value is left alone. Windows +# Installer's evaluator runs the SetProperty condition and then $Component's own condition. +function Assert-KeptOnUpdate([string] $Property, [string] $Release, [string] $Found, [string] $FoundValue, [string] $Component, [string] $What) { + Assert-NoRow ('SELECT `Value` FROM `Property` WHERE `Property` = ''' + $Property + '''') "$What static default" + $action = Read-Row ('SELECT `Action`, `Target` FROM `CustomAction` WHERE `Source` = ''' + $Property + '''') 2 + Assert-Equal $action[1] '1' "$What default value" + $condition = $null + foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { + $search = Read-Row ('SELECT `Sequence` FROM `' + $sequence + '` WHERE `Action` = ''AppSearch''') 1 + $set = Read-Row ('SELECT `Sequence`, `Condition` FROM `' + $sequence + '` WHERE `Action` = ''' + $action[0] + '''') 2 + if ([int] $set[0] -le [int] $search[0]) { throw "$sequence must set the $What default after AppSearch" } + $condition = $set[1] + } + $installed = Read-Row ('SELECT `Condition` FROM `Component` WHERE `Component` = ''' + $Component + '''') 1 + Assert-Equal $installed[0] "$Property = 1" "$Component condition" + $old = '{00000000-0000-0000-0000-000000000000}' + # Name, command-line value, WIX_UPGRADE_DETECTED, $Release, $Found, installed (1) or not (0). + $cases = @( + @('first install', '', '', '', '', 1), + @("first install, $Property=0", '0', '', '', '', 0), + @('update, still there', '', $old, $old, $FoundValue, 1), + @('update, removed', '', $old, $old, '', 0), + @("update, $Property=0", '0', $old, $old, $FoundValue, 0), + @("update, removed, $Property=1", '1', $old, $old, '', 1)) + # An update from a release from before it existed (WIX_UPGRADE_DETECTED alone) is a first install. + if ($Release -ne 'WIX_UPGRADE_DETECTED') { $cases += , @('update from a release without it', '', $old, '', '', 1) } + $session = $null + try { + $session = $Installer.OpenPackage((Resolve-Path -LiteralPath $Path).Path, 1) + foreach ($case in $cases) { + $session.Property($Property) = $case[1] + $session.Property('WIX_UPGRADE_DETECTED') = $case[2] + $session.Property($Release) = $case[3] + $session.Property($Found) = $case[4] + if ($session.EvaluateCondition($condition) -eq 1) { $session.Property($Property) = $action[1] } + Assert-Equal ($session.EvaluateCondition($installed[0])) $case[5] "$What`: $($case[0])" + } + } finally { + if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } } -} finally { - if ($session) { [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($session) } } +Assert-KeptOnUpdate 'INSTALLDESKTOPSHORTCUT' 'WIX_UPGRADE_DETECTED' 'EXISTINGDESKTOPSHORTCUT' 'C:\Users\Public\Desktop\PdfCraft.lnk' 'PdfcraftDesktopShortcut' 'Desktop shortcut' $secure = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''SecureCustomProperties''' 1 if (($secure[0] -split ';') -notcontains 'INSTALLDESKTOPSHORTCUT') { throw "INSTALLDESKTOPSHORTCUT is not secure: '$($secure[0])'" } $checkbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''DesktopShortcut''' 3 @@ -128,23 +145,71 @@ Assert-Equal $app[0] 'PdfcraftExe' 'Shortcut executable key path' $scope = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS''' 1 Assert-Equal $scope[0] '1' 'Per-machine shortcut scope' -# Image context menu opens the DPI chooser; the app component owns every registry row so -# uninstall removes it. No image default association is changed. +# Each File Explorer menu remembers whether it was installed by finding its key path in HKLM, in the +# package's own registry view (64-bit for x64 and arm64). Only a release that offered the menu can +# leave it out, so the OnlyDetect Upgrade rows start at the first release with that menu. +$upgradeCode = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''UpgradeCode''' 1 +$view64 = $Database.SummaryInformation(0).Property(7) -notmatch '^Intel;' +function Assert-MenuSearch([string] $Found, [string] $Key, [string] $Release, [string] $Since, [string] $What) { + $search = Read-Row ('SELECT `Root`, `Key`, `Name`, `Type` FROM `RegLocator`, `AppSearch` WHERE `AppSearch`.`Signature_` = `RegLocator`.`Signature_` AND `AppSearch`.`Property` = ''' + $Found + '''') 4 + Assert-Equal $search[0] '2' "$What search HKLM root" + Assert-Equal $search[1] $Key "$What search key" + Assert-Equal $search[2] '' "$What search reads the default value" + # msidbLocatorTypeRawValue (2), plus msidbLocatorType64bit (16) in a 64-bit package. + Assert-Equal ([int] $search[3]) $(if ($view64) { 18 } else { 2 }) "$What search type" + $upgrade = Read-Row ('SELECT `UpgradeCode`, `VersionMin`, `VersionMax`, `Attributes` FROM `Upgrade` WHERE `ActionProperty` = ''' + $Release + '''') 4 + Assert-Equal $upgrade[0] $upgradeCode[0] "$What release detection upgrade code" + Assert-Equal $upgrade[1] $Since "$What release detection minimum" + Assert-Equal $upgrade[2] '' "$What release detection maximum" + # msidbUpgradeAttributesOnlyDetect (2) + VersionMinInclusive (256): never removes anything itself. + Assert-Equal ([int] $upgrade[3]) 258 "$What release detection attributes" + if (($secure[0] -split ';') -notcontains $Release) { throw "$Release is not secure: '$($secure[0])'" } +} +# The old version is removed before the new one is installed, so rows that moved between components +# (the image menu left PdfcraftApp after 0.7.1) are removed with it rather than orphaned. +$validate = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''InstallValidate''' 1 +$remove = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''RemoveExistingProducts''' 1 +$initialize = Read-Row 'SELECT `Sequence` FROM `InstallExecuteSequence` WHERE `Action` = ''InstallInitialize''' 1 +if ([int] $remove[0] -le [int] $validate[0] -or [int] $remove[0] -ge [int] $initialize[0]) { + throw "RemoveExistingProducts ($($remove[0])) must run between InstallValidate and InstallInitialize" +} + +# Image context menu (#190) opens the DPI chooser. Its own component, gated by INSTALLIMAGECONTEXTMENU +# (on for a first install, kept on update only while present, secure), with the .png label as key +# path so uninstall removes every row. No image default association is changed. +$imageKey = 'Software\Classes\SystemFileAssociations\.png\shell\PdfCraft.CreatePdf' foreach ($ext in @('png', 'jpg', 'jpeg', 'tif', 'tiff', 'gif', 'bmp', 'jp2', 'j2k', 'jpx')) { $key = 'Software\Classes\SystemFileAssociations\.' + $ext + '\shell\PdfCraft.CreatePdf' $menu = Read-Row ('SELECT `Value`, `Component_`, `Root` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` IS NULL') 3 Assert-Equal $menu[0] 'Create PDF with PdfCraft…' "$ext context menu label" - Assert-Equal $menu[1] 'PdfcraftApp' "$ext context menu component" + Assert-Equal $menu[1] 'PdfcraftImageContextMenu' "$ext context menu component" Assert-Equal $menu[2] '2' "$ext context menu HKLM root" - $command = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 1 + $command = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + '\command''') 2 Assert-Equal $command[0] '"[#PdfcraftExe]" --create-images "%1"' "$ext context menu command" - $selection = Read-Row ('SELECT `Value` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 1 + Assert-Equal $command[1] 'PdfcraftImageContextMenu' "$ext context menu command component" + $selection = Read-Row ('SELECT `Value`, `Component_` FROM `Registry` WHERE `Key` = ''' + $key + ''' AND `Name` = ''MultiSelectModel''') 2 Assert-Equal $selection[0] 'Single' "$ext context menu selection" + Assert-Equal $selection[1] 'PdfcraftImageContextMenu' "$ext context menu selection component" } +$view = $Database.OpenView('SELECT `Key` FROM `Registry` WHERE `Component_` = ''PdfcraftApp''') +try { + [void] $view.Execute() + while ($record = $view.Fetch()) { + if ($record.StringData(1) -match 'PdfCraft\.CreatePdf') { throw "image menu row left in PdfcraftApp: $($record.StringData(1))" } + } +} finally { [void] $view.Close() } +$imageKeyPath = Read-Row ('SELECT `Registry` FROM `Registry` WHERE `Key` = ''' + $imageKey + ''' AND `Name` IS NULL') 1 +$imageComponent = Read-Row 'SELECT `KeyPath`, `Attributes` FROM `Component` WHERE `Component` = ''PdfcraftImageContextMenu''' 2 +Assert-Equal $imageComponent[0] $imageKeyPath[0] 'Image context menu key path' +# msidbComponentAttributesRegistryKeyPath (4). +if (([int] $imageComponent[1] -band 4) -eq 0) { throw "Image context menu key path is not a registry value: attributes $($imageComponent[1])" } +Assert-KeptOnUpdate 'INSTALLIMAGECONTEXTMENU' 'IMAGECONTEXTMENURELEASE' 'EXISTINGIMAGECONTEXTMENU' 'Create PDF with PdfCraft…' 'PdfcraftImageContextMenu' 'Image context menu' +Assert-MenuSearch 'EXISTINGIMAGECONTEXTMENU' $imageKey 'IMAGECONTEXTMENURELEASE' '0.4.0' 'Image context menu' +if (($secure[0] -split ';') -notcontains 'INSTALLIMAGECONTEXTMENU') { throw "INSTALLIMAGECONTEXTMENU is not secure: '$($secure[0])'" } # PDF context menu (#682): "Edit with PdfCraft" opens the file in PdfCraft. Its own component, gated -# by INSTALLPDFCONTEXTMENU (default 1, secure, welcome checkbox), with its menu value as key path so -# uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the +# by INSTALLPDFCONTEXTMENU (on for a first install, kept on update only while present, secure, welcome +# checkbox), with its menu value as key path so uninstall removes it. Several selected PDFs each start pdfcraft.exe "%1" (Document), which the # single-instance hand-off opens as tabs. The .pdf default is never set, so the user's default app # stays. $pdfKey = 'Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit' @@ -163,8 +228,8 @@ Assert-Equal $pdfCommand[1] 'PdfcraftPdfContextMenu' 'PDF context menu command c $pdfComponent = Read-Row 'SELECT `Condition`, `KeyPath` FROM `Component` WHERE `Component` = ''PdfcraftPdfContextMenu''' 2 Assert-Equal $pdfComponent[0] 'INSTALLPDFCONTEXTMENU = 1' 'PDF context menu condition' Assert-Equal $pdfComponent[1] $pdfMenu[0] 'PDF context menu key path' -$pdfDefault = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''INSTALLPDFCONTEXTMENU''' 1 -Assert-Equal $pdfDefault[0] '1' 'PDF context menu default' +Assert-KeptOnUpdate 'INSTALLPDFCONTEXTMENU' 'PDFCONTEXTMENURELEASE' 'EXISTINGPDFCONTEXTMENU' 'Edit with PdfCraft' 'PdfcraftPdfContextMenu' 'PDF context menu' +Assert-MenuSearch 'EXISTINGPDFCONTEXTMENU' $pdfKey 'PDFCONTEXTMENURELEASE' '0.7.1' 'PDF context menu' if (($secure[0] -split ';') -notcontains 'INSTALLPDFCONTEXTMENU') { throw "INSTALLPDFCONTEXTMENU is not secure: '$($secure[0])'" } $pdfCheckbox = Read-Row 'SELECT `Type`, `Property`, `Text` FROM `Control` WHERE `Dialog_` = ''InstallWelcome'' AND `Control` = ''PdfContextMenu''' 3 Assert-Equal $pdfCheckbox[0] 'CheckBox' 'Welcome PDF context-menu control' @@ -204,4 +269,4 @@ $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Installer) -Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut (on for a first install, kept on update only while present, checkbox), optional PDF context menu (default on, checkbox), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' +Write-Output 'ok MSI: per-machine scope guard, publisher, persistent progress text, Start Menu shortcut, optional desktop shortcut and PDF and image context menus (on for a first install, kept on update only while present; checkboxes for the first two), icon/key path, full-UI success/cancel/error and Finish controls, files-in-use dialog, OCR models' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ad337a87e8..957ac5c3e1 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -346,8 +346,8 @@ milestone = "M1" status = "shipped" commands = [] tools = [] -evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output"] -notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle)" +evidence = ["crates/cos/tests/encryption.rs::qpdf_agrees", "crates/cos/tests/writer.rs::qpdf_accepts_object_stream_output", "crates/platform/src/staging.rs::an_atomic_write_replaces_the_target_only_when_complete", "apps/pdfcraft-cli/tests/edit.rs::editing_in_place_leaves_a_valid_file_and_no_staging_file", "crates/automation/tests/automation.rs::a_root_less_write_output_is_atomic_too"] +notes = "qpdf is installed on the Linux CI runner and PDFCRAFT_ORACLES_REQUIRED makes its absence a failure, so these run on every CI run rather than skipping; on a machine without qpdf they skip with a notice (crates/oracle). Every PDF leaves the engine checked (it reopens with its pages): saves, new files from Combine/Extract/Split/Create, signed, timestamped and LTV-embedded files, OCR batch output; and every writer goes through one staging-file writer (the desktop app, the tools with or without --root, the CLI), so a failed write never leaves a truncated file (#1698, #2166)" [[feature]] id = "core.deterministic-save" @@ -550,8 +550,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] -notes = "" +evidence = ["crates/ui-egui/tests/recovery.rs::a_crashed_session_is_offered_and_recovered_unsaved", "crates/ui-egui/tests/recovery.rs::discarding_removes_the_autosaves", "crates/ui-egui/tests/recovery.rs::a_running_instances_autosaves_are_not_offered_to_a_second_instance", "crates/engine/src/tests.rs::recovered_documents_reopen_unsaved_at_their_original_path"] +notes = "A running instance holds an exclusively locked instance file in the recovery folder and names it in its snapshots, so a second instance neither offers nor discards what the first is autosaving (#2166); the OS releases the lock when the process dies. Where the folder can't be written or the platform has no file locks, snapshots are offered as before." [[feature]] id = "core.encrypted-autosave" @@ -646,8 +646,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one"] -notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert." +evidence = ["crates/engine/src/tests.rs::reload_reads_the_new_file_into_the_same_document", "crates/ui-egui/tests/reload.rs::a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom", "crates/ui-egui/tests/reload.rs::a_half_written_file_waits_until_the_pdf_is_whole", "crates/ui-egui/tests/reload.rs::saving_does_not_reload_or_lose_undo", "crates/ui-egui/tests/reload.rs::unsaved_changes_are_kept_until_reload_is_chosen", "crates/ui-egui/tests/reload.rs::a_deleted_file_leaves_the_document_open", "crates/ui-egui/tests/reload.rs::nothing_reloads_with_the_preference_off", "crates/ui-egui/tests/reload.rs::a_shorter_new_version_drops_what_pointed_into_the_old_one", "crates/ui-egui/tests/reload.rs::saving_over_a_file_changed_by_something_else_asks_first", "crates/ui-egui/tests/reload.rs::saving_checks_the_file_with_the_watcher_off_too", "crates/ui-egui/tests/reload.rs::opening_a_file_that_is_open_focuses_its_tab"] +notes = "Desktop, on by default (Preferences ▸ Documents and view, #431), for LaTeX/Typst builds: a worker thread checks each open file's metadata every 250 ms and reads a change once it has settled and ends in %%EOF; a clean document reloads in place (same tab, page, zoom, scroll; old page images stay until new ones are drawn), one with unsaved changes gets a Reload notice; PdfCraft's own saves never reload. Save asks before writing over a version of the file the document did not come from, watcher on or off, with Save as… to keep it (#2166); a file that is open already comes to the front instead of opening twice. Measured on Windows: one check 11 µs per file, reload of a 417-page 0.9 MB pdflatex file 16 ms. Not yet: OS change notifications (polling adds up to 250 ms), the web build; a reload clears undo history, like Revert." [[feature]] id = "core.signed-save-policy" @@ -746,8 +746,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied"] -notes = "via hayro bootstrap" +evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"] +notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" [[feature]] id = "view.render-knockout" @@ -806,8 +806,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" [[feature]] id = "view.render-error-isolation" @@ -842,8 +842,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps"] -notes = "capped at 8192 px per side (M3.3)" +evidence = ["crates/render/src/raster.rs::tiles_match_full_render", "crates/render/src/raster.rs::effective_scale_never_exceeds_the_caps", "crates/ui-egui/tests/view.rs::returning_to_a_visited_zoom_reuses_its_cached_tiles", "crates/ui-egui/tests/view.rs::zooming_a_tiled_page_shows_its_earlier_tiles_until_new_ones_arrive"] +notes = "capped at 8192 px per side (M3.3); tiles of a zoom already rendered stay cached while their page is on screen, so zooming away and back shows the area sharp instead of re-rendering it tile by tile (#2196)" [[feature]] id = "view.layout-continuous" @@ -854,8 +854,8 @@ milestone = "M3" status = "shipped" commands = ["view.continuous"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past"] -notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes" +evidence = ["crates/ui-egui/tests/view.rs::continuous_layout_stacks_pages_vertically", "crates/ui-egui/tests/view.rs::page_display_commands_switch_layouts", "crates/ui-egui/tests/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/src/canvas.rs::fit_page_and_height_hold_still_while_mixed_page_sizes_scroll_past", "crates/ui-egui/tests/view.rs::touchpad_swipe_glides_on_after_the_fingers_lift_and_stops", "crates/ui-egui/tests/view.rs::touching_the_touchpad_again_or_turning_animations_off_stops_the_glide"] +notes = "the default for PDFs that don't ask for a layout (Preferences ▸ Default page display); pages stack with no snapping. Fit page and Fit height fit the largest page, so the zoom holds still across mixed page sizes. A touchpad swipe glides on after the fingers lift on Wayland (#531; macOS glides by itself; Windows and X11 report no lift through winit), unless Animation effects is off; a click, key, zoom or new scroll catches it. Not yet: fingers resting on the touchpad don't catch it (needs Wayland hold gestures, which the vendored winit doesn't bind)" [[feature]] id = "view.layout-single-page" @@ -866,8 +866,8 @@ milestone = "M3" status = "shipped" commands = ["view.layout.single"] tools = [] -evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"] -notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in, the wheel pans and never turns the page. Switched from the View menu, the rail's Page display button or the palette" +evidence = ["crates/ui-egui/tests/view.rs::single_page_layout_shows_one_page_at_a_time", "crates/ui-egui/tests/view.rs::single_page_wheel_pans_instead_when_zoomed_in", "crates/ui-egui/tests/view.rs::wheel_turns_one_page_per_notch_and_per_trackpad_swipe", "crates/ui-egui/tests/view.rs::single_page_wheel_at_the_bottom_edge_turns_to_the_next_page_top_and_back", "crates/ui-egui/tests/view.rs::single_page_swipe_that_reaches_the_edge_does_not_turn_but_a_fresh_one_does", "crates/ui-egui/tests/view.rs::rail_page_display_button_switches_layouts", "crates/ui-egui/tests/view.rs::view_menu_page_display_uses_the_same_commands"] +notes = "when the whole page fits, each wheel notch turns a page and each trackpad swipe turns one, momentum included; zoomed in so the page is taller than the window, the wheel pans, and at the bottom (top) edge a wheel notch, or a fresh trackpad swipe, shows the next page at its top (the previous page at its bottom); a swipe that panned to the edge, momentum included, does not turn until the fingers lift and swipe again (#1924). Not verified against Acrobat's own thresholds. Switched from the View menu, the rail's Page display button or the palette" [[feature]] id = "view.layout-two-up" @@ -1164,10 +1164,10 @@ area = "B" tier = "P1" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.previous_view", "view.next_view"] tools = [] -evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options"] -notes = "⌘[ / ⌘] and View ▸ Page navigation" +evidence = ["crates/ui-egui/tests/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/control.rs::routine_continuous_scrolling_does_not_add_navigation_history"] +notes = "Per-tab page history: explicit search-result jumps, page navigation, bookmarks and links; no continuous-scroll history or restart persistence; existing page-step history is retained. Rail buttons, View ▸ Page navigation and Ctrl/⌘[ / Ctrl/⌘]. UI control ui.command exposes view.previous_view / view.next_view; standalone engine sessions have no viewport history. Restores page tops, not exact scroll/zoom positions." [[feature]] id = "view.follow-links" @@ -1193,6 +1193,18 @@ tools = [] evidence = ["crates/ui-egui/tests/comments.rs::hovering_a_comment_shows_its_author_and_text"] notes = "a popup with the author and the comment text, as Acrobat shows" +[[feature]] +id = "view.page-size-hover" +title = "Page size on hover (bottom-left of the page view)" +area = "B" +tier = "P2" +milestone = "M3" +status = "shipped" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/control.rs::page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant", "crates/ui-egui/src/canvas.rs::page_size_hint_zone_is_the_bottom_left_quadrant"] +notes = "While the pointer is in the bottom-left quadrant of the page view, a small label shows the current page's size (after /Rotate and /UserUnit) in the Preferences unit, formatted as in Document Properties (#1855). It never takes clicks and hides when the pointer leaves. Acrobat's trigger is the corner itself and may differ in exact area and wording (not compared with real Acrobat); the size of the current page only, not of the page under the pointer; no headless tool (page sizes are in document info)." + [[feature]] id = "view.document-tabs" title = "Tabbed documents" @@ -1706,8 +1718,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions"] -notes = "" +evidence = ["crates/ui-egui/tests/commands.rs::shortcuts_run_registered_commands", "crates/engine/src/commands.rs::shortcut_labels_follow_platform_conventions", "crates/ui-egui/tests/commands.rs::quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work", "crates/ui-egui/tests/signing.rs::ctrl_alt_q_types_an_email_without_quitting"] +notes = "Quit requires exact Ctrl+Q/Cmd+Q modifiers and preserves unsaved-document confirmation. Ctrl+Alt+Q email input is covered with synthetic key/text events; native German keyboard-layout translation is not covered by that test (#1980)." [[feature]] id = "view.shortcuts-dialog" @@ -1854,8 +1866,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools"] -notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/ui.rs::the_edit_tab_turns_on_edit_text_and_images"] +notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" @@ -1998,8 +2010,8 @@ milestone = "M7" status = "shipped" commands = ["edit.text"] tools = ["page_add_text", "content_update"] -evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen"] -notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" +evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page", "crates/automation/tests/automation.rs::added_arabic_text_keeps_copy_search_and_editor_order_after_retyping_and_reopen", "crates/edit/src/tests.rs::added_hebrew_text_is_drawn_right_to_left_and_stays_searchable", "crates/automation/tests/automation.rs::hebrew_text_through_tools_saves_reopens_extracts_and_renders"] +notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; per-line ActualText keeps copy, search and editor text in typing order through retyping and save/reopen; it is drawn regular whatever the bold/italic setting, and typing in the box is still left to right (misc.rtl-ui). Hebrew (#128) takes the same path with the craft-fonts Hebr face (Noto Sans Hebrew): right-to-left display order, points and cantillation kept on their letter as one glyph, ToUnicode and per-line ActualText; regular style only, and right-to-left scripts other than Arabic and Hebrew are not covered. Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box" [[feature]] id = "edit.find-replace" @@ -2079,11 +2091,11 @@ title = "Crop an image" area = "C" tier = "P0" milestone = "M7" -status = "partial" +status = "shipped" commands = ["edit.image"] -tools = ["content_update"] -evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] -notes = "for added images: trim each side, clipped" +tools = ["content_update", "image_edit"] +evidence = ["crates/ui-egui/tests/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"] +notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed." [[feature]] id = "edit.image-rotate-flip" @@ -2608,8 +2620,10 @@ milestone = "M4" status = "shipped" commands = ["page.organize"] tools = [] -evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel"] -notes = "Opening the Organize pages tool enters the overview directly. Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection." +evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/editing.rs::organize_opens_on_the_current_page", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel"] +notes = "Opening the Organize pages tool enters the overview directly, on the current page: selected and scrolled into view (#1910). Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection." +evidence = ["crates/ui-egui/tests/editing.rs::shift_click_selects_a_range_and_moves_it", "crates/ui-egui/tests/editing.rs::organize_tool_opens_the_overview_directly_and_can_be_reentered", "crates/ui-egui/tests/control.rs::organize_overview_is_reachable_through_the_control_channel", "crates/ui-egui/tests/editing.rs::organize_panel_comes_with_the_grid_and_closes_it", "crates/ui-egui/tests/editing.rs::leaving_organize_goes_back_to_the_workspace_panel"] +notes = "Opening the Organize pages tool enters the overview directly. Hovered, focused and selected pages expose individual rotate/delete actions; the toolbar acts on the selection. The left panel is laid out as Acrobat's Organize pages panel: Pages selected (typed ranges, or All, Even, Odd, Landscape or Portrait pages; empty, it shows the current page), rotate, delete, extract, Insert (a blank page, pages from a file, duplicates), Replace, Split, Bates numbering, Set page boxes, Crop pages, Print pages and Page labels. Closing it, by its Close, the grid's Close or opening a page, returns to the workspace's own panel (Edit, Convert, E-Sign or All tools). Page transitions, Page templates and Page properties are listed but planned." [[feature]] id = "organize.multi-select-pages" @@ -2704,8 +2718,8 @@ milestone = "M4" status = "shipped" commands = ["page.insert"] tools = ["page_insert_file"] -evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] -notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app." +evidence = ["crates/organize/src/tests.rs::inserting_pages_from_another_file_is_an_incremental_edit", "crates/organize/src/tests.rs::inserting_a_file_brings_its_bookmarks_under_one_named_after_it", "crates/automation/tests/automation.rs::inserting_a_file_brings_its_bookmarks", "crates/ui-egui/tests/editing.rs::inserting_a_file_goes_after_the_selection_and_undoes", "crates/ui-egui/tests/editing.rs::plus_between_pages_inserts_picked_files_there_in_order"] +notes = "After the selection (toolbar, File menu) or at any gap of the page grid (a + between pages and at both ends); several files at once, in order; images and .txt files are converted first; the inserted pages end up selected. The file's bookmarks come along, collapsed under one named after the file, placed among the document's bookmarks where the pages went (as Combine does per file). The page_insert_file tool takes PDFs only. Missing: choosing which pages of the file to insert in the app; the file's tags (structure tree) are not merged." [[feature]] id = "organize.insert-from-clipboard" @@ -3181,11 +3195,14 @@ title = "Page transitions" area = "D" tier = "P2" milestone = "M4" -status = "planned" +status = "partial" commands = [] +tools = ["doc_info"] +evidence = ["crates/render/src/inspect.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/automation.rs::doc_info_lists_page_transitions_and_display_times"] +notes = "Read only: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults for what is missing or out of range, and doc_info lists them (DocInfo::transitions). Not yet: playing them in full screen, auto-advancing on /Dur, or the Page Transitions dialog that sets them" tools = [] evidence = [] -notes = "" +notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" [[feature]] id = "organize.page-tab-order" @@ -3460,8 +3477,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept" +evidence = ["crates/ui-egui/tests/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/automation.rs::initial_view_through_tools"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen)" [[feature]] id = "organize.custom-properties" @@ -3728,8 +3745,8 @@ milestone = "M5" status = "shipped" commands = ["comment.square"] tools = ["comment_add", "comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties"] -notes = "resize with the 8 handles; fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)" +evidence = ["crates/ui-egui/tests/comments.rs::shapes_and_ink_are_drawn_by_dragging", "crates/annot/src/tests.rs::restyle_and_resize_redraw_the_appearance", "crates/annot/src/tests.rs::shapes_can_be_filled_and_unfilled", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool"] +notes = "resize with the 8 handles, straight after drawing with the tool still on (#2338); fill colour or no fill (#686) from the quick bar, Properties and comment_edit `fill`; fill swatches are the comment palette only (no custom colour picker yet)" [[feature]] id = "comment.oval" @@ -3848,8 +3865,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = ["comment_edit"] -evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties"] -notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon), General (author, subject), Review History" +evidence = ["crates/ui-egui/tests/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/comments.rs::text_box_properties_change_the_font_size", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/automation.rs::text_box_font_size_through_the_tool"] +notes = "Appearance (colour, fill colour for rectangles/ovals/polygons, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)" [[feature]] id = "comment.properties-bar" @@ -3884,8 +3901,8 @@ milestone = "M5" status = "shipped" commands = ["comment.list"] tools = [] -evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default"] -notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default", "crates/ui-egui/tests/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] +notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" [[feature]] id = "comment.author-identity" @@ -4196,8 +4213,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "appearances become page content; pop-ups and replies removed" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted" [[feature]] id = "comment.migrate" @@ -4404,7 +4421,9 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"] +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4)" +evidence = ["crates/ui-egui/tests/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value"] notes = "in-place editor; Enter or clicking away commits, Escape cancels; no format/keystroke JavaScript yet (M6.4)" [[feature]] @@ -4464,7 +4483,7 @@ milestone = "M6" status = "partial" commands = ["form.fields"] tools = ["form_fill", "form_set_props"] -evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"] +evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::auto_text_size_fits_single_and_multiline_text", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/engine/tests/form_rotation.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"] notes = "auto-size, comb, multiline, password, quadding, /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics" [[feature]] @@ -4716,8 +4735,8 @@ milestone = "M6" status = "shipped" commands = ["form.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] -notes = "widgets drawn into content and pruned from the field tree" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_a_valued_widget_it_cannot_draw", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/automation.rs::flattening_through_tools"] +notes = "widgets drawn into content and pruned from the field tree; a widget with no appearance to draw (none stored, or none for its selected state) stays a live field rather than losing its value, so a form with such fields is not fully flattened — Acrobat regenerates appearances first, which PdfCraft does not yet do here" [[feature]] id = "form.prepare-form-mode" @@ -4728,8 +4747,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_add_field", "form_set_props", "form_delete_field"] -evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] -notes = "fields shown with blue fill and name tags; select, move, resize by corners, Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" +evidence = ["crates/ui-egui/tests/prepare.rs::placing_moving_editing_and_deleting_a_field", "crates/ui-egui/src/prepare.rs::corner_drags_stretch_freely_and_keep_proportions_with_shift", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel", "crates/engine/src/tests.rs::preparing_a_form_adds_renames_and_deletes_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::shared_properties_preserve_mixed_values_and_undo_the_whole_change", "crates/ui-egui/tests/ui_group/bulk_fields.rs::select_all_in_prepare_a_form_selects_only_the_current_pages_fields", "crates/ui-egui/tests/ui_group/bulk_fields.rs::dragging_an_empty_page_area_selects_fields_without_moving_them"] +notes = "fields shown with blue fill and name tags; select, move, resize by corners (Shift keeps the field's proportions, #2199), Delete (every selected field, one undo step); field tools return to Select after one field. Shared General, Appearance and applicable Options edits are atomic and opt-in; Ctrl/Command+A selects the current page, rectangle and panel selection are supported. Names, positions, choices/defaults and script tabs remain individual GUI edits" [[feature]] id = "form.add-text-field" @@ -5241,11 +5260,11 @@ title = "Flatten XFA forms" area = "F" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.flatten"] +tools = ["doc_flatten", "xfa_to_static"] +evidence = ["crates/xfa/src/tests.rs::stripping_takes_the_xfa_out_and_leaves_an_ordinary_document", "crates/engine/src/tests.rs::flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out", "crates/engine/src/tests.rs::an_xfa_form_converts_to_a_static_form_that_keeps_its_fields", "crates/automation/tests/automation.rs::xfa_forms_flatten_and_convert_to_static_through_tools"] +notes = "Flatten form fields on a laid-out XFA form bakes the fields into the pages and takes the XFA out (packets, /NeedsRendering, PdfCraft's private keys), so every viewer, Adobe's included, shows the baked pages rather than laying the form out again from the template. xfa_to_static (tool and engine edit) keeps the laid-out pages and the fields with their values as an ordinary AcroForm whose scripts no longer run. Both undoable until saved. Missing: a menu entry for the conversion (the tool and the MCP server have it); flattening keeps the layout as PdfCraft laid it out, not as Acrobat would (form.xfa-dynamic)" [[feature]] id = "form.fill-sign-text" @@ -5292,8 +5311,10 @@ milestone = "M5" status = "shipped" commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"] tools = ["fill_sign_add", "comment_edit", "comment_image_preview"] -evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"] -notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour", "crates/ui-egui/tests/fill_sign.rs::undo_button_and_ctrl_z_take_back_one_drawn_stroke_at_a_time", "crates/ui-egui/src/fill_sign.rs::undo_removes_the_last_stroke_and_skips_empty_ones"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn (an Undo button and Ctrl/Cmd+Z in the pad take back the last stroke; Clear empties it) or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" +evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_signatures_resize_from_their_corners", "crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/src/comments.rs::shift_keeps_the_current_proportions_unless_the_box_has_its_own", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::large_signature_images_are_scaled_down", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save", "crates/ui-egui/tests/fill_sign.rs::fill_and_sign_tools_pick_up_placed_marks_to_move_and_resize_them", "crates/ui-egui/tests/fill_sign.rs::select_tool_picks_up_a_signature_over_a_form_field", "crates/ui-egui/tests/fill_sign.rs::scanned_signatures_are_framed_cleaned_adjusted_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::scan_cancel_errors_and_stale_answers_preserve_saved_signatures", "crates/automation/tests/automation.rs::scanned_signatures_through_tools_drop_the_paper_and_report_bad_arguments", "crates/engine/src/signature_scan.rs::a_photo_is_scanned_into_a_trimmed_transparent_signature_in_its_ink_colour"] +notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; while a Fill & Sign tool is active, marks already placed are picked up as with Select (hover shows the move cursor, a click selects, a drag moves, the selected mark's handles resize) and the tool places only away from them; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; holding Shift on a corner handle keeps any resizable comment's current proportions (#2199); native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 32 megapixels and 8192 pixels per side, larger than 4 megapixels or 4096 pixels per side scaled down to fit (#1075); Scan tab (fill_sign_add scan/sensitivity headless): a phone photo of a signature on paper (PNG/JPEG within the same image limits, EXIF-oriented, kept at most 4 megapixels) is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn and the photo turned in quarter turns, and an ink-sensitivity slider (0-100) controls how faint ink is kept; the paper is estimated by a morphological closing, so shadows and uneven light are removed, and the result is trimmed ink in its own colour on transparency, saved like an imported image. Still missing: perspective correction of a skewed sheet, live camera capture, keyboard control of the frame, and processing off the UI thread (bounded by the 4-megapixel working copy; quick 900 px previews while dragging)" [[feature]] id = "form.fill-sign-flatten" @@ -5641,11 +5662,11 @@ title = "Redaction fill colour and overlay text" area = "G" tier = "P0" milestone = "M8" -status = "partial" +status = "shipped" commands = ["redact.properties"] tools = ["redact_mark"] -evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"] -notes = "fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes)" +evidence = ["crates/ui-egui/tests/ui_group/redact.rs::marking_applying_and_clearing", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_survive_a_restart", "crates/ui-egui/tests/ui_group/redact.rs::redaction_properties_for_this_run_only_and_cancel", "crates/redact/src/tests.rs::overlay_text_takes_its_font_size_colour_alignment_and_repeats"] +notes = "Acrobat's three groups: redacted area fill colour or none; overlay text with font, size or auto-size, colour, alignment and repeat, or redaction codes (protect.redaction-codes); Redaction mark appearance: outline colour (/C), fill colour shown while the pointer is over a mark (a preference, not written), opacity (/CA). Make properties default: ticked, OK keeps the values for the next run (settings \"redact_prefs\"); unticked, this run only and the saved defaults stay; Cancel puts back what the dialog opened with." [[feature]] id = "protect.redaction-codes" @@ -5998,8 +6019,8 @@ milestone = "M9" status = "partial" commands = ["sign.digital"] tools = ["sign_document"] -evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools"] -notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. Missing: named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog" +evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature", "crates/sign/tests/pdf.rs::visible_signatures_show_a_drawn_typed_or_image_graphic", "crates/sign/tests/pdf.rs::malformed_signature_graphics_are_refused_not_drawn", "crates/sign/tests/pdf.rs::an_encrypted_documents_signature_graphic_is_encrypted_with_it", "crates/automation/tests/automation.rs::signing_with_an_image_typed_or_drawn_graphic_through_tools", "crates/sign/tests/pdf.rs::appearance_labels_helvetica_cannot_draw_are_refused", "crates/ui-egui/tests/signing.rs::the_signature_labels_follow_the_interface_language", "crates/automation/tests/automation.rs::signing_with_translated_labels_through_tools"] +notes = "text items (name, date, reason, location, DN, labels), and a graphic where the large name would be: the saved Fill & Sign signature (drawn, typed or image) or an imported PNG/JPEG (white paper made transparent, as in Fill & Sign), remembered for the next signing; alone it fills the box. The labels are stamped in the interface language when Helvetica (WinAnsi) can draw all of them (German, Spanish, French, Italian, Portuguese, Polish, Turkish, Slovene, Hungarian), otherwise all in English with a note under the preview, which shows the same labels and the signing time; sign_document takes labels and the shown items (appearance), and a drawn label the font can't draw is refused, never written as ?. Missing: labels in non-Latin scripts (needs an embedded font), the DN line in the preview, named saved appearances (one remembered choice only), a PDF page as the graphic, the logo/watermark background, drawing a new signature inside the dialog" [[feature]] id = "sign.reason-location" @@ -6310,8 +6331,8 @@ milestone = "M9" status = "shipped" commands = ["sign.panel"] tools = ["sign_list"] -evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing"] -notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level)" +evidence = ["crates/sign/tests/pdf.rs::later_changes_are_classified_under_the_signature_permissions", "crates/sign/tests/pdf.rs::signing_an_existing_field_and_counter_signing", "crates/engine/src/tests.rs::signing_saving_trusting_and_commenting_afterwards", "crates/sign/tests/pdf.rs::validation_data_and_xmp_added_after_signing_are_not_tampering", "crates/sign/tests/pdf.rs::a_dss_entry_does_not_make_the_object_it_names_part_of_the_store", "crates/sign/tests/pdf.rs::an_existing_dictionary_is_not_a_store_part_because_the_store_names_it", "crates/sign/tests/pdf.rs::an_existing_array_stays_what_it_is_when_the_store_points_at_it", "crates/sign/tests/pdf.rs::a_signed_store_may_grow_but_not_lose_or_swap_entries", "crates/sign/tests/pdf.rs::a_dictionary_labelled_metadata_is_not_a_metadata_change", "crates/sign/tests/pdf.rs::a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it", "crates/sign/tests/pdf.rs::the_catalogs_own_xmp_stream_may_still_be_rewritten_after_signing", "crates/sign/tests/pdf.rs::rewritten_page_resources_are_page_content_whatever_keys_they_carry"] +notes = "revision diff classified as signing, form fill, comments, metadata, page content or structure. Later revisions that add DSS data (new /Certs, /CRLs, /OCSPs, /VRI objects, or ones of the signed store that only grew) or rewrite the catalog's XMP are permitted changes; a /DSS entry that names other objects, or a /Type /DSS or /Type /Metadata label on an existing dictionary or stream, does not excuse changing them (negative tests at every DocMDP level). A signed page's resources (fonts, XObjects, graphics states and what they reach) are page content by the role they held when signed, whatever keys a later revision writes into them (#1787)" [[feature]] id = "sign.view-signed-version" @@ -6967,7 +6988,7 @@ status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells, per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7252,6 +7273,8 @@ commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize", "crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_picks_a_preset", "crates/automation/tests/automation.rs::a_page_printed_in_strips_gets_small_through_tools", "crates/optimize/tests/optimize.rs::the_mobile_preset_makes_a_printed_page_small"] notes = "Settings menu with the Standard and Mobile presets (Custom once an option changes; doc_optimize preset), Images (with Merge image fragments: a page picture drawn as one-pixel strips, as some PDF printers write it, is put back together so it can be resampled), Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels, no saved custom presets, no space audit" +evidence = ["crates/ui-egui/tests/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/ui-egui/src/optimize_ui.rs::the_progress_card_shows_the_stage_and_cancels", "crates/engine/src/tests.rs::an_optimize_job_reports_its_stages_and_can_be_cancelled", "crates/optimize/tests/optimize.rs::images_and_forms_nothing_draws_are_dropped", "crates/automation/tests/automation.rs::a_deleted_image_is_not_kept_by_reduce_or_optimize"] +notes = "Images, Discard Objects, Discard User Data, Clean Up (also drops images and forms the pages list but never draw, such as deleted images); runs in the background with a progress bar (per image) and Cancel; no Fonts or Transparency panels" [[feature]] id = "optimize.downsample-images" @@ -7264,6 +7287,8 @@ commands = ["optimize.advanced"] tools = ["doc_optimize"] evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone"] notes = "colour and grayscale, bicubic, effective resolution measured where drawn; image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/automation.rs::optimizing_through_tools"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); monochrome not yet" [[feature]] id = "optimize.recompress-images" @@ -7586,8 +7611,8 @@ milestone = "M11" status = "shipped" commands = ["optimize.advanced"] tools = ["doc_audit_space"] -evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage"] -notes = "PDF Optimizer ▸ Audit space usage: 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages" +evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/viewer.rs::space_audit_numbers_line_up_under_their_headers"] +notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" [[feature]] id = "optimize.optimizer-presets" @@ -8103,11 +8128,11 @@ title = "Print in grayscale" area = "L" tier = "P1" milestone = "M10" -status = "partial" +status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list"] -notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); the PDF itself stays in colour" +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"] +notes = "asks the printer for monochrome (lp -o, and the Windows page settings as the job's); with Print as image the pictures themselves are rendered grey (Rec. 601 luma), so the print-ready PDF is grayscale too. Still missing against Acrobat: a grey conversion of the vector pages without rendering them." [[feature]] id = "print.print-as-image" @@ -8115,11 +8140,11 @@ title = "Print as image" area = "L" tier = "P1" milestone = "M10" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["print.dialog"] +tools = ["doc_print"] +evidence = ["crates/engine/src/tests.rs::printing_as_image_sends_pictures_instead_of_pages", "crates/ui-egui/tests/print.rs::print_as_image_is_an_advanced_choice_that_saves_pictures", "crates/automation/tests/automation.rs::doc_print_as_image"] +notes = "The Print dialog's Advanced section (folded unless on): Print as image at 150, 300 or 600 dpi; doc_print as_image and dpi (72–600). The pages used are rendered once each the way the viewer shows them (comments left out when the print leaves them out), composed over white, made into an image-only document and imposed on the paper like the pages themselves. The print is no longer searchable text. Still missing against Acrobat: per-printer defaults for it." [[feature]] id = "print.page-setup" @@ -8878,8 +8903,8 @@ milestone = "M12" status = "partial" commands = ["a11y.tags"] tools = ["accessibility_tags"] -evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag"] -notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982." +evidence = ["crates/ui-egui/tests/accessibility.rs::tags_panel_edits_a_tag_property_and_undoes_it", "crates/ui-egui/tests/accessibility.rs::checker_finding_opens_its_structure_tag", "crates/automation/tests/automation.rs::structure_tags_are_listed_a_page_at_a_time"] +notes = "The right-side Tags panel lists reachable indirect tags as a collapsible hierarchy, filters by type or title with ancestors, navigates to their page and edits properties. accessibility_tags lists them a page at a time (offset, limit, next) and can keep only some types, pages or depths. Checker findings linked to indirect tags open the matching tag without losing unsaved edits. Inline tags, adding/deleting tags, artifact marking and finding content remain planned in #982." [[feature]] id = "a11y.tag-properties" @@ -9155,11 +9180,11 @@ title = "Side-by-side compare view with synchronised scrolling" area = "N" tier = "P1" milestone = "M12" -status = "planned" -commands = [] +status = "shipped" +commands = ["doc.compare"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/compare.rs::compare_two_versions"] +notes = "After Compare files the document area splits (when the old version is open in a tab and the window is at least 1000 px wide; the Compare panel's Side by side / New file only toggles it): the old file on the left, the new on the right, each a page view of its own with its shading (deleted and replaced on the old, inserted and replaced on the new, visual differences on both). The half the pointer is over leads: the other takes its zoom, fit, layout, rotation and scroll offset; a change clicked in the panel jumps both halves to their side of it. Clear ends it. Still missing against Acrobat: scrolling by matching content rather than by offset (pages of different sizes drift), and the comparison kept when the old file's tab closes." [[feature]] id = "misc.compare-report" @@ -9410,8 +9435,8 @@ milestone = "M13" status = "shipped" commands = [] tools = [] -evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed"] -notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors." +evidence = ["crates/automation/tests/automation.rs::tool_table_is_well_formed", "crates/automation/tests/automation.rs::edit_undo_redo_and_save_round_trip", "apps/pdfcraft-cli/tests/stdout.rs::output_commands_exit_cleanly_when_the_reader_has_closed", "apps/pdfcraft-cli/tests/run_report.rs::a_failing_step_is_named_and_the_rest_are_skipped"] +notes = "CLI stdout exits cleanly if a pipe reader closes early; other output errors and command failures remain errors. `run --script --report` prints one JSON document with a status per step (#1891); without it the output is unchanged." [[feature]] id = "misc.cli-completeness" @@ -9926,8 +9951,10 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish translates them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said"] +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish and Italian translate them and the other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading" @@ -10083,7 +10110,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut and an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682), and confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." +notes = "Windows MSI creates a Start menu shortcut, an optional (default on) desktop shortcut, an optional (default on, INSTALLPDFCONTEXTMENU) \"Edit with PdfCraft\" File Explorer menu entry for PDF files (#682) and an optional (default on, INSTALLIMAGECONTEXTMENU) \"Create PDF with PdfCraft…\" entry for images; an update keeps whichever of the three the installed version has (#1058, #305). It confirms full-UI completion with Finish (#143); packaging/windows/test-msi.ps1 checks the compiled MSI and windows-arm64.yml checks installation/removal. PDF files use a dedicated embedded document icon when PdfCraft is chosen as the default reader (confirmed in Windows Explorer); first-page thumbnails remain unsupported. Signed installers across platforms remain planned." [[feature]] id = "misc.web-deploy" diff --git a/vendor/README.md b/vendor/README.md index 564e505797..3cc347c2c3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -5,23 +5,28 @@ licence files unchanged. Every patch is marked `PdfCraft patch:` in the source, PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases the fix. Vendoring copyleft code is never allowed (plan/adr/0001). +A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing one-point floor as a raw reciprocal of `/UserUnit`, rounded upward only when f32 multiplication would fall below one point. Five `pdfcraft-render` regressions with prefix `raster::tests::raw_crop_` cover the observed dimensions, origins and rotations, units 41/47, bounded vector rasters and unchanged nearly-zero A4 fallback. Accepted page-unit policy and licences are unchanged. + | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours` +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text` | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled` | hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs` |; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height` +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed` | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references` | -| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session | +| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | +| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index d3654871a7..1138b658f0 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -60,6 +60,9 @@ pub(crate) struct FormXObject<'a> { pub(crate) matrix: Affine, pub(crate) bbox: [f32; 4], is_transparency_group: bool, + // PdfCraft patch: the group's /I and /K (ISO 32000-2 §11.6.6, both false by default). + is_isolated: bool, + is_knockout: bool, pub(crate) dict: Dict<'a>, resources: Dict<'a>, } @@ -77,12 +80,17 @@ impl<'a> FormXObject<'a> { .unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), ); let bbox = dict.get::<[f32; 4]>(BBOX)?; - let is_transparency_group = dict.get::>(GROUP).is_some(); + let group = dict.get::>(GROUP); + let is_transparency_group = group.is_some(); + let is_isolated = group.as_ref().and_then(|g| g.get::(I)).unwrap_or(false); + let is_knockout = group.as_ref().and_then(|g| g.get::(K)).unwrap_or(false); Some(Self { decoded, matrix, is_transparency_group, + is_isolated, + is_knockout, bbox, dict: dict.clone(), resources, @@ -134,7 +142,26 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.pre_concat_affine(x_object.matrix); context.push_root_transform(); - if x_object.is_transparency_group { + // PdfCraft patch: a transparency group that is neither isolated nor knockout composites + // its elements with the backdrop already on the page (§11.4.7), so when it is entered at + // full opacity with no soft mask and Normal blending there is nothing for a layer of its + // own to add, and drawing straight onto the page is the exact result. A layer here was + // wrong, not just slower: a blend mode inside the group (the `/BM /Multiply` of a highlight + // appearance written by other editors) saw the layer's transparent backdrop instead of the + // text, and the layer then covered the page as an opaque block (#1968). An isolated group + // starts from a blank backdrop by definition, and a knockout group or one entered at partial + // opacity, under a soft mask or with a blend mode keeps its layer, where the group's + // elements still composite with a blank backdrop (an approximation of §11.4.7 for them). + let neutral = !x_object.is_isolated && !x_object.is_knockout && { + let state = &context.get().graphics_state; + state.non_stroke_alpha >= 1.0 + && state.stroke_alpha >= 1.0 + && state.soft_mask.is_none() + && state.blend_mode == BlendMode::Normal + }; + let own_layer = x_object.is_transparency_group && !neutral; + + if own_layer { device.push_transparency_group( context.get().graphics_state.non_stroke_alpha, std::mem::take(&mut context.get_mut().graphics_state.soft_mask), @@ -169,7 +196,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>( device.pop_clip_path(); - if x_object.is_transparency_group { + if own_layer { device.pop_transparency_group(); } diff --git a/vendor/hayro-syntax/src/filter/ascii_85.rs b/vendor/hayro-syntax/src/filter/ascii_85.rs index 5a18cf8352..943bfeb742 100644 --- a/vendor/hayro-syntax/src/filter/ascii_85.rs +++ b/vendor/hayro-syntax/src/filter/ascii_85.rs @@ -4,7 +4,9 @@ use crate::reader::Reader; use crate::trivia::is_white_space_character; use alloc::vec::Vec; -pub(crate) fn decode(data: &[u8]) -> Option> { +/// PdfCraft patch: `limit` is the most bytes produced (see `filter::decode_limit`); each `z` +/// expands one input byte to four. +pub(crate) fn decode(data: &[u8], limit: usize) -> Option> { const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; let mut reader = Reader::new(data); @@ -50,15 +52,21 @@ pub(crate) fn decode(data: &[u8]) -> Option> { .checked_add(digits[3].checked_mul(POW_85[3])?)? .checked_add(digits[4])?; - decoded.extend_from_slice(&value.to_be_bytes()[..output_len]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend_from_slice(&value.to_be_bytes()[..output_len.min(left)]); group.clear(); Some(()) }; - let mut decoded = Vec::with_capacity(data.len() * 4 / 5); + let mut decoded = Vec::with_capacity((data.len() / 5 * 4).min(limit)); let mut group = Vec::with_capacity(5); loop { + // PdfCraft patch: output cut at the limit is reported, never dropped silently. + if decoded.len() >= limit { + warn!("ASCII85 stream stopped at its decode limit of {limit} bytes"); + return Some(decoded); + } let Some(b) = read_byte() else { // Be lenient and accept what we have (see PDFBOX-5910). flush_group(&mut group, &mut decoded)?; @@ -76,7 +84,8 @@ pub(crate) fn decode(data: &[u8]) -> Option> { } b'z' => { flush_group(&mut group, &mut decoded)?; - decoded.extend_from_slice(&[0, 0, 0, 0]); + let left = limit.saturating_sub(decoded.len()); + decoded.extend(core::iter::repeat_n(0, left.min(4))); } b'~' => { // Technically requires a '>', but there is a PDF where it isn't diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index 22bebf6d32..06bda29487 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -95,7 +95,7 @@ impl Filter { Self::AsciiHexDecode => ascii_hex::decode(data) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), - Self::Ascii85Decode => ascii_85::decode(data) + Self::Ascii85Decode => ascii_85::decode(data, limit) .map(FilterResult::from_data) .ok_or(DecodeFailure::StreamDecode), Self::RunLengthDecode => run_length::decode(data, limit) diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index 7d63931a32..d7f0ec9537 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -558,9 +558,17 @@ impl<'a> Page<'a> { { (A4.width() as f32, A4.height() as f32) } else { + // PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit. + // The stored unit is finite and within 1..=75000. Round upward only if the + // reciprocal's f32 product would fall below one and trigger a placeholder. + let mut raw_floor = 1.0 / self.user_unit; + if raw_floor * self.user_unit < 1.0 { + raw_floor = raw_floor.next_up(); + } + let raw_floor = f64::from(raw_floor); ( - crop_box.width().max(1.0) as f32, - crop_box.height().max(1.0) as f32, + crop_box.width().max(raw_floor) as f32, + crop_box.height().max(raw_floor) as f32, ) } } diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 23fc2e36ca..45bb2350bf 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1075,8 +1075,13 @@ impl Renderer { // become native gradients instead of a texture sampled pixel by pixel. // Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already // evaluates gradients at pixel centres, like the texture below. - if let Some(native) = - encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE) + // PdfCraft patch (#2128): vello_cpu 0.0.8 copies an opaque gradient + // over fully covered tiles without applying the mask or blend mode, and + // a gradient can't be flagged transparent like the texture below, so + // under a soft mask or blend mode the shading is sampled instead. + if !self.force_images_may_have_transparency() + && let Some(native) = + encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE) { self.ctx .set_paint_transform(path_transform.inverse() * native.transform); diff --git a/vendor/vello_common/CHANGELOG.md b/vendor/vello_common/CHANGELOG.md new file mode 100644 index 0000000000..912b7648ae --- /dev/null +++ b/vendor/vello_common/CHANGELOG.md @@ -0,0 +1,246 @@ + + +# Changelog + +## [Unreleased] + +This release has an [MSRV][] of 1.88. + +## [0.0.8][] - 2026-05-15 + +This release has an [MSRV][] of 1.88. + +### Added + +- `TextureId`, a handle for user-provided textures resolved at render-time, and `EncodedExternalTexture` for encoding draws that sample from them. ([#1552][] by [@tomcur][]) +- `RectU16`, a shared axis-aligned rectangle type for `u16` coordinate bounding boxes. ([#1559][] by [@tomcur][]) + +### Changed + +- `WideTilesBbox` now uses `RectU16` internally, its `bbox` field is private, and `WideTilesBbox::new` now takes four `u16` coordinates instead of a `[u16; 4]`. ([#1559][] by [@tomcur][]) +- Renamed image transparency hint APIs from `may_have_opacities` and `*_opacity_hint` to `may_have_transparency` and `*_transparency_hint`. ([#1613][] by [@upsuper][]) +- Gradient LUTs no longer reserve an extra transparent entry for undefined radial-gradient regions; `EncodedGradient` now tracks whether those regions are possible. ([#1529][] by [@LaurenzV][]) + +### Removed + +- Public `glyph` and `colr` modules and the `text` feature; sparse-strips text rendering now lives in `glifo` and the renderer crates. ([#1562][] by [@LaurenzV][]) +- Support for recordings, including the shared recording API. + This decision was made due to a number of downsides that came with the implementation. + See the corresponding PR and Zulip thread for more information. ([#1611][] by [@LaurenzV][]) + +### Fixed + +- Handling of zero-area rectangles in the rectangle fast path, avoiding out-of-viewport strips and downstream assertions. ([#1537][] by [@LaurenzV][]) +- Invalid gradients with NaN or out-of-range final stop offsets now fall back to the first color instead of passing validation. ([#1531][] by [@LaurenzV][]) +- Implicit subpaths that continue after `ClosePath` without an explicit `MoveTo`. ([#1544][] by [@tomcur][]) +- Negative-sized rectangles during strip generation. ([#1589][] by [@LaurenzV][]) + +### Optimized + +- Rendering performance for paths with line segments fully or partly left of the viewport by culling invisible left-of-viewport line work while preserving winding contributions. ([#1368][] by [@b0nes164][]) +- Performance of clipped drawing by culling geometry outside the clip bounding box during flattening. ([#1519][] by [@tomcur][]) +- Flattening and tiling performance through codegen improvements in the sparse strip pipeline. ([#1600][], [#1616][], [#1634][] by [@LaurenzV][]) + +## [0.0.7][] - 2026-03-24 + +This release has an [MSRV][] of 1.92. + +### Added + +- Support for image tinting. ([#1460][] by [@grebmeg][]) +- `RenderState` abstraction. ([#1486][] by [@LaurenzV][]) +- Helpers for rendering filters. ([#1446][] by [@LaurenzV][]) +- `ImageCache` and `MultiAtlasManager`. ([#1457][] by [@grebmeg][]) + +### Changed + +- Replaced custom blend mode representation with peniko `Mix` for filter effects. ([#1410][] by [@LaurenzV][]) + +### Removed + +- Process Row Incremental Macro and Perfect Bit. ([#1384][] by [@b0nes164][]) +- `tolerance` parameter from the flattening method. ([#1399][] by [@LaurenzV][]) +- Command annotation logic. ([#1438][] by [@LaurenzV][]) + +### Fixed + +- Double-scaling of non-hinted COLR glyphs. ([#1370][] by [@conor-93][]) +- Handling of `pop_clip` commands and properly resetting wide tiles. ([#1443][] by [@LaurenzV][], [#1447][] by [@tomcur][]) +- Layer ranges not being updated when a wide tile is reset with a background color. ([#1478][] by [@LaurenzV][]) + +### Optimized + +- Bézier flattening performance by catching more Béziers whose chords are immediately within rendering tolerance. ([#1216][] by [@tomcur][]) +- Flattening and tiling performance by culling out-of-viewport Béziers before flattening. ([#1341][] by [@tomcur][]) +- Rendering performance of scenes including blend layers by ensuring no commands are generated for wide tiles without layer content. ([#1403][] by [@tomcur][]) +- Scenes including blend layers by only allocating layer scratch buffers for a wide tile upon draw commands being performed in that wide tile. ([#1414][] by [@tomcur][]) +- Analytic anti-aliasing. ([#1426][], [#1442][] by [@tomcur][]) +- Filter application to layers. ([#1444][] by [@LaurenzV][]) +- Rendering pixel-aligned rectangles. ([#1453][] by [@grebmeg][]) +- Coarse rasterization and scheduling of scenes without layers. ([#1454][] by [@LaurenzV][]) +- Resetting wide tiles. ([#1484][] by [@LaurenzV][]) + +## [0.0.6][] - 2026-01-15 + +This release has an [MSRV][] of 1.88. + +### Changed + +- Breaking change: Updated Peniko to [v0.6.0](https://github.com/linebender/peniko/releases/tag/v0.6.0). ([#1349][] by [@DJMcNab][]) + - This also updates Kurbo to [v0.13.0](https://github.com/linebender/kurbo/releases/tag/v0.13.0). +- Upgraded Skrifa to v0.40.0. ([#1353][] by [@waywardmonkeys][]) +- Upgraded Hashbrown to v0.16.1. ([#1354][] by [@waywardmonkeys][]) + +### Optimized + +- Alpha blending for fully opaque image fills. ([#1329][] by [@grebmeg][]) + +See also the [vello_hybrid 0.0.6](../vello_hybrid/CHANGELOG.md#006---2026-01-15) and [vello_cpu 0.0.6](../vello_cpu/CHANGELOG.md#006---2026-01-15) releases. + +## [0.0.5][] - 2026-01-08 + +This release has an [MSRV][] of 1.88. + +### Added + +- A new module `clip` allowing for the possibility to intersect two strips to create a new strip representing their intersection. ([#1203][] by [@LaurenzV]) +- An `extend` method to `StripStorage` to extend its alphas/strips from another `StripStorage`. ([#1203][] by [@LaurenzV]) +- A new trait for approximate integer division by 255. ([#1203][] by [@LaurenzV]) +- A `from_parts` method for masks. ([#1237][] by [@LaurenzV]) +- Initial support for image filters. ([#1286][] by [@grebmeg]) + +### Changed + +- `WideTile::generate` now takes an additional `BlendMode` as a parameter. ([#1159][] by [@LaurenzV]) +- `CmdFill` and `CmdAlphaFill` now store a `BlendMode` instead of `Option`. ([#1159][] by [@LaurenzV]) +- `Strip` now implements `PartialEq` and `Eq`. ([#1203][] by [@LaurenzV]) +- `Strip` now has a `is_sentinel` method. ([#1203][] by [@LaurenzV]) +- `StripStorage` now implements `PartialEq` and `Eq`. ([#1203][] by [@LaurenzV]) +- The `generate_filled_path` method of `StripGenerator` now takes an optional clip path as input. ([#1203][] by [@LaurenzV]) +- The `generate` method of `Wide` now takes an optional mask as an additional argument. ([#1237][] by [@LaurenzV]) +- `CmdFill` and `CmdAlphaFill` now store an optional mask. ([#1237][] by [@LaurenzV]) +- Various changes to the logic for computing tile intersections and representation of tiles. ([#1293][], [#1317][], [#1318][] by [@b0nes164]) +- Support for computing data necessary to implement multi-sampled anti-aliasing. ([#1319][], by [@b0nes164]) + +### Optimized + +- Gradient rendering. ([#1301][] by [@valadaptive]) +- Numerous performance and memory-efficiency improvements. ([#1325][] by [@LaurenzV], [#1327][] by [@grebmeg], [#1336][] by [@tomcur], [#1338][] by [@taj-p]) + +See also the [vello_hybrid 0.0.5](../vello_hybrid/CHANGELOG.md#005---2026-01-08) and [vello_cpu 0.0.5](../vello_cpu/CHANGELOG.md#005---2026-01-08) releases. + +## [0.0.4][] - 2025-10-17 + +This release has an [MSRV][] of 1.86. + +No changelog was kept for this release. + +See also the [vello_hybrid 0.0.4](../vello_hybrid/CHANGELOG.md#004---2025-10-17) and [vello_cpu 0.0.4](../vello_cpu/CHANGELOG.md#004---2025-10-17) releases. + +## [0.0.3][] - 2025-10-04 + +This release has an [MSRV][] of 1.86. + +No changelog was kept for this release. + +See also the [vello_cpu 0.0.3](../vello_cpu/CHANGELOG.md#003---2025-10-04) release. + +## [0.0.2][] - 2025-09-22 + +This release has an [MSRV][] of 1.85. + +No changelog was kept for this release. + +See also the [vello_cpu 0.0.2](../vello_cpu/CHANGELOG.md#002---2025-09-22) release. + +## [0.0.1][] - 2025-05-10 + +This release has an [MSRV][] of 1.85. + +This is the initial release. No changelog was kept for this release. + +See also the [vello_cpu 0.0.1](../vello_cpu/CHANGELOG.md#001---2025-05-10) release. + +[@b0nes164]: https://github.com/b0nes164 +[@conor-93]: https://github.com/conor-93 +[@DJMcNab]: https://github.com/waywardmonkeys +[@grebmeg]: https://github.com/grebmeg +[@LaurenzV]: https://github.com/LaurenzV +[@taj-p]: https://github.com/taj-p +[@tomcur]: https://github.com/tomcur +[@upsuper]: https://github.com/upsuper +[@valadaptive]: https://github.com/valadaptive +[@waywardmonkeys]: https://github.com/waywardmonkeys + +[#1159]: https://github.com/linebender/vello/pull/1159 +[#1203]: https://github.com/linebender/vello/pull/1203 +[#1216]: https://github.com/linebender/vello/pull/1216 +[#1237]: https://github.com/linebender/vello/pull/1237 +[#1286]: https://github.com/linebender/vello/pull/1286 +[#1293]: https://github.com/linebender/vello/pull/1293 +[#1301]: https://github.com/linebender/vello/pull/1301 +[#1317]: https://github.com/linebender/vello/pull/1317 +[#1318]: https://github.com/linebender/vello/pull/1318 +[#1319]: https://github.com/linebender/vello/pull/1319 +[#1325]: https://github.com/linebender/vello/pull/1325 +[#1327]: https://github.com/linebender/vello/pull/1327 +[#1329]: https://github.com/linebender/vello/pull/1329 +[#1336]: https://github.com/linebender/vello/pull/1327 +[#1338]: https://github.com/linebender/vello/pull/1327 +[#1341]: https://github.com/linebender/vello/pull/1341 +[#1349]: https://github.com/linebender/vello/pull/1349 +[#1353]: https://github.com/linebender/vello/pull/1353 +[#1354]: https://github.com/linebender/vello/pull/1354 +[#1368]: https://github.com/linebender/vello/pull/1368 +[#1399]: https://github.com/linebender/vello/pull/1399 +[#1403]: https://github.com/linebender/vello/pull/1403 +[#1414]: https://github.com/linebender/vello/pull/1414 +[#1370]: https://github.com/linebender/vello/pull/1370 +[#1384]: https://github.com/linebender/vello/pull/1384 +[#1410]: https://github.com/linebender/vello/pull/1410 +[#1426]: https://github.com/linebender/vello/pull/1426 +[#1438]: https://github.com/linebender/vello/pull/1438 +[#1442]: https://github.com/linebender/vello/pull/1442 +[#1443]: https://github.com/linebender/vello/pull/1443 +[#1444]: https://github.com/linebender/vello/pull/1444 +[#1446]: https://github.com/linebender/vello/pull/1446 +[#1447]: https://github.com/linebender/vello/pull/1447 +[#1453]: https://github.com/linebender/vello/pull/1453 +[#1454]: https://github.com/linebender/vello/pull/1454 +[#1457]: https://github.com/linebender/vello/pull/1457 +[#1460]: https://github.com/linebender/vello/pull/1460 +[#1478]: https://github.com/linebender/vello/pull/1478 +[#1484]: https://github.com/linebender/vello/pull/1484 +[#1486]: https://github.com/linebender/vello/pull/1486 +[#1519]: https://github.com/linebender/vello/pull/1519 +[#1529]: https://github.com/linebender/vello/pull/1529 +[#1531]: https://github.com/linebender/vello/pull/1531 +[#1537]: https://github.com/linebender/vello/pull/1537 +[#1544]: https://github.com/linebender/vello/pull/1544 +[#1552]: https://github.com/linebender/vello/pull/1552 +[#1559]: https://github.com/linebender/vello/pull/1559 +[#1562]: https://github.com/linebender/vello/pull/1562 +[#1589]: https://github.com/linebender/vello/pull/1589 +[#1600]: https://github.com/linebender/vello/pull/1600 +[#1611]: https://github.com/linebender/vello/pull/1611 +[#1613]: https://github.com/linebender/vello/pull/1613 +[#1616]: https://github.com/linebender/vello/pull/1616 +[#1634]: https://github.com/linebender/vello/pull/1634 + +[Unreleased]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.8...HEAD +[0.0.8]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.7...sparse-strips-v0.0.8 +[0.0.7]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.6...sparse-strips-v0.0.7 +[0.0.6]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.5...sparse-strips-v0.0.6 +[0.0.5]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.4...sparse-strips-v0.0.5 +[0.0.4]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.3...sparse-strips-v0.0.4 +[0.0.3]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.2...sparse-strips-v0.0.3 +[0.0.2]: https://github.com/linebender/vello/compare/sparse-strips-v0.0.1...sparse-stips-v0.0.2 +[0.0.1]: https://github.com/linebender/vello/compare/ca6b1e4c7f5b0d95953c3b524f5d3952d5669c5a...sparse-strips-v0.0.1 + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/vendor/vello_common/Cargo.lock b/vendor/vello_common/Cargo.lock new file mode 100644 index 0000000000..72d0743ff4 --- /dev/null +++ b/vendor/vello_common/Cargo.lock @@ -0,0 +1,310 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", + "libm", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76258897e51fd156ee03b6246ea53f3e0eb395d0b327e9961c4fc4c8b2fa151a" +dependencies = [ + "libm", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "libm", + "polycool", + "smallvec", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", + "libm", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vello_common" +version = "0.0.8" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown", + "libm", + "log", + "peniko", + "png", + "roxmltree", + "smallvec", + "thiserror", +] diff --git a/vendor/vello_common/Cargo.toml b/vendor/vello_common/Cargo.toml new file mode 100644 index 0000000000..988ffb939e --- /dev/null +++ b/vendor/vello_common/Cargo.toml @@ -0,0 +1,158 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +rust-version = "1.88" +name = "vello_common" +version = "0.0.8" +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Core data structures and utilities shared across the Vello rendering, including geometry processing and tiling logic." +readme = "README.md" +keywords = [ + "2d", + "vector-graphics", +] +categories = [ + "rendering", + "graphics", +] +license = "Apache-2.0 OR MIT" +repository = "https://github.com/linebender/vello" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[features] +default = [ + "std", + "png", +] +libm = [ + "peniko/libm", + "dep:libm", + "fearless_simd/libm", +] +multithreading = ["std"] +pico_svg = ["dep:roxmltree"] +png = [ + "std", + "dep:png", +] +probe = [] +std = [ + "peniko/std", + "fearless_simd/std", +] + +[lib] +name = "vello_common" +path = "src/lib.rs" + +[dependencies.bytemuck] +version = "1.25.0" +features = [ + "derive", + "derive", +] + +[dependencies.fearless_simd] +version = "0.4.0" +default-features = false + +[dependencies.guillotiere] +version = "0.7.0" +default-features = false + +[dependencies.hashbrown] +version = "0.17.1" +features = [ + "default-hasher", + "raw-entry", +] +default-features = false + +[dependencies.libm] +version = "0.2.16" +optional = true + +[dependencies.log] +version = "0.4.29" + +[dependencies.peniko] +version = "0.6.1" +features = ["bytemuck"] +default-features = false + +[dependencies.png] +version = "0.18.1" +optional = true + +[dependencies.roxmltree] +version = "0.20.0" +optional = true + +[dependencies.smallvec] +version = "1.15.1" + +[dependencies.thiserror] +version = "2.0.18" +default-features = false + +[lints.clippy] +allow_attributes_without_reason = "warn" +cargo_common_metadata = "warn" +cast_possible_truncation = "warn" +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +missing_assert_message = "warn" +missing_fields_in_debug = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +should_panic_without_expect = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_debug_implementations = "warn" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/vendor/vello_common/Cargo.toml.orig b/vendor/vello_common/Cargo.toml.orig new file mode 100644 index 0000000000..fa356c16d4 --- /dev/null +++ b/vendor/vello_common/Cargo.toml.orig @@ -0,0 +1,53 @@ +[package] +name = "vello_common" +# When updating, also update the version in the workspace dependency in the root Cargo.toml +version = "0.0.8" +description = "Core data structures and utilities shared across the Vello rendering, including geometry processing and tiling logic." +categories = ["rendering", "graphics"] +keywords = ["2d", "vector-graphics"] +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[package.metadata.docs.rs] +all-features = true +# There are no platform specific docs. +default-target = "x86_64-unknown-linux-gnu" +targets = [] + +[dependencies] +bytemuck = { workspace = true, features = ["derive"] } +peniko = { workspace = true, features = ["bytemuck"] } +fearless_simd = { workspace = true } +hashbrown = { workspace = true, features = ["raw-entry"] } +png = { workspace = true, optional = true } +roxmltree = { version = "0.20.0", optional = true } +smallvec = { workspace = true } +thiserror = { workspace = true, default-features = false } +guillotiere = { workspace = true } +libm = { version = "0.2.16", optional = true } +log = { workspace = true } + +[features] +# If adding new features, also document in `src/lib.rs` +default = ["std", "png"] +# Get floating point functions from the standard library (likely using your target's libc). +std = ["peniko/std", "fearless_simd/std"] +# Use floating point implementations from libm. +libm = ["peniko/libm", "dep:libm", "fearless_simd/libm"] +# Allow loading Pixmap from PNG, and drawing png glyphs. +png = ["std", "dep:png"] +# Add helpers for drawing a probe scene. +probe = [] +# Enable multi-threaded rendering. +multithreading = ["std"] +# Development only features + +# Enable a simple version of SVG drawing. +# This is only intended for development of Vello CPU itself, +# and is likely to be removed/moved. +pico_svg = ["dep:roxmltree"] + +[lints] +workspace = true diff --git a/vendor/vello_common/LICENSE-APACHE b/vendor/vello_common/LICENSE-APACHE new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/vendor/vello_common/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/vendor/vello_common/LICENSE-MIT b/vendor/vello_common/LICENSE-MIT new file mode 100644 index 0000000000..48ff8e414b --- /dev/null +++ b/vendor/vello_common/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright 2020 the Vello Authors + +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/vendor/vello_common/README.md b/vendor/vello_common/README.md new file mode 100644 index 0000000000..27fa3f770e --- /dev/null +++ b/vendor/vello_common/README.md @@ -0,0 +1,100 @@ +
+ +# Vello Common + +**Shared data structures** + +[![Latest published version.](https://img.shields.io/crates/v/vello_common.svg)](https://crates.io/crates/vello_common) +[![Documentation build status.](https://img.shields.io/docsrs/vello_common.svg)](https://docs.rs/vello_common) +[![Apache 2.0 or MIT license.](https://img.shields.io/badge/license-Apache--2.0_OR_MIT-blue.svg)](#license) +\ +[![Linebender Zulip chat.](https://img.shields.io/badge/Linebender-%23vello-blue?logo=Zulip)](https://xi.zulipchat.com/#narrow/channel/197075-vello) +[![GitHub Actions CI status.](https://img.shields.io/github/actions/workflow/status/linebender/vello/ci.yml?logo=github&label=CI)](https://github.com/linebender/vello/actions) +[![Dependency staleness status.](https://deps.rs/crate/vello_common/latest/status.svg)](https://deps.rs/crate/vello_common) + +
+ + + + + +[libm]: https://crates.io/crates/libm +[crate::pixmap::Pixmap]: https://docs.rs/vello_common/latest/vello_common/pixmap/struct.Pixmap.html + + +This crate includes common geometry representations, tiling logic, and other fundamental components used by both [Vello CPU][vello_cpu] and Vello Hybrid. + +## Usage + +This crate should not be used on its own, and you should instead use one of the renderers which use it. +At the moment, only [Vello CPU][vello_cpu] is published, and you probably want to use that. + +We also develop [Vello](https://crates.io/crates/vello), which makes use of the GPU for 2D rendering and has higher performance than Vello CPU. +Vello CPU is being developed as part of work to address shortcomings in Vello. +Vello does not use this crate. + +## Features + +- `std` (enabled by default): Get floating point functions from the standard library + (likely using your target's libc). +- `libm`: Use floating point implementations from [libm][]. +- `png` (enabled by default): Allow loading [`Pixmap`][crate::pixmap::Pixmap]s from PNG images. + Also required for rendering glyphs with an embedded PNG. + Implies `std`. + +At least one of `std` and `libm` is required; `std` overrides `libm`. + +## Contents + +- Shared data structures for paths, tiles, and strips +- Geometry processing utilities +- Common logic for rendering stages + +This crate acts as a foundation for `vello_cpu` and `vello_hybrid`, providing essential components to minimize duplication. + +[vello_cpu]: https://crates.io/crates/vello_cpu + + + +## Minimum supported Rust Version (MSRV) + +This version of Vello Common has been verified to compile with **Rust 1.92** and later. + +Future versions of Vello Common might increase the Rust version requirement. +It will not be treated as a breaking change and as such can even happen with small patch releases. + +
+Click here if compiling fails. + +As time has passed, some of Vello Common's dependencies could have released versions with a higher Rust requirement. +If you encounter a compilation issue due to a dependency and don't want to upgrade your Rust toolchain, then you could downgrade the dependency. + +```sh +# Use the problematic dependency's name and version +cargo update -p package_name --precise 0.1.1 +``` + +
+ +## Community + +Discussion of Vello Common development happens in the [Linebender Zulip](https://xi.zulipchat.com/), specifically the [#vello channel](https://xi.zulipchat.com/#narrow/channel/197075-vello). +All public content can be read without logging in. + +Contributions are welcome by pull request. +The [Rust code of conduct] applies. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +[Rust code of conduct]: https://www.rust-lang.org/policies/code-of-conduct diff --git a/vendor/vello_common/assets/README.md b/vendor/vello_common/assets/README.md new file mode 100644 index 0000000000..f7fc3bace5 --- /dev/null +++ b/vendor/vello_common/assets/README.md @@ -0,0 +1,2 @@ +In order to regenerate `probe.png` and `probe.rgba`, please run +`cargo run --bin regenerate_probe_reference` inside of `vello_sparse_tests`. diff --git a/vendor/vello_common/assets/probe.png b/vendor/vello_common/assets/probe.png new file mode 100644 index 0000000000..8464880a06 Binary files /dev/null and b/vendor/vello_common/assets/probe.png differ diff --git a/vendor/vello_common/assets/probe.rgba b/vendor/vello_common/assets/probe.rgba new file mode 100644 index 0000000000..35238209af Binary files /dev/null and b/vendor/vello_common/assets/probe.rgba differ diff --git a/vendor/vello_common/src/blurred_rounded_rect.rs b/vendor/vello_common/src/blurred_rounded_rect.rs new file mode 100644 index 0000000000..13f62e33fb --- /dev/null +++ b/vendor/vello_common/src/blurred_rounded_rect.rs @@ -0,0 +1,19 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Blurred, rounded rectangles. +use crate::color::{AlphaColor, Srgb}; +use crate::kurbo::Rect; + +/// A blurred, rounded rectangle. +#[derive(Debug)] +pub struct BlurredRoundedRectangle { + /// The base rectangle to use for the blur effect. + pub rect: Rect, + /// The color of the blurred rectangle. + pub color: AlphaColor, + /// The radius of the rounded rectangle's corners. + pub radius: f32, + /// The standard deviation of the blur effect. + pub std_dev: f32, +} diff --git a/vendor/vello_common/src/clip.rs b/vendor/vello_common/src/clip.rs new file mode 100644 index 0000000000..c50a0ff9dd --- /dev/null +++ b/vendor/vello_common/src/clip.rs @@ -0,0 +1,996 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Managing clipping state. + +use crate::geometry::RectU16; +use crate::kurbo::{Affine, BezPath, PathEl, Rect}; +use crate::strip::Strip; +use crate::strip_generator::{GenerationMode, StripGenerator, StripStorage}; +use crate::tile::Tile; +use crate::util::normalized_mul_u8x16; +use alloc::vec; +use alloc::vec::Vec; +use fearless_simd::{Level, Simd, SimdBase, dispatch, u8x16}; +use peniko::Fill; + +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +#[derive(Debug)] +struct ClipData { + alpha_start: u32, + strip_start: u32, + + /// A coarse bounding box of the clip path in pixel coordinates. + /// + /// These bounds have already been intersected with the viewport. + bbox: RectU16, +} + +impl ClipData { + fn to_path_data_ref<'a>(&self, storage: &'a StripStorage) -> PathDataRef<'a> { + PathDataRef { + strips: storage + .strips + .get(self.strip_start as usize..) + .unwrap_or(&[]), + alphas: storage + .alphas + .get(self.alpha_start as usize..) + .unwrap_or(&[]), + bbox: self.bbox, + } + } +} + +/// A context for managing clip stacks. +#[derive(Debug)] +pub struct ClipContext { + storage: StripStorage, + temp_storage: StripStorage, + clip_stack: Vec, +} + +impl Default for ClipContext { + fn default() -> Self { + Self::new() + } +} + +impl ClipContext { + /// Create a new clip context. + #[inline] + pub fn new() -> Self { + let mut main_storage = StripStorage::default(); + main_storage.set_generation_mode(GenerationMode::Append); + Self { + storage: main_storage, + temp_storage: StripStorage::default(), + clip_stack: vec![], + } + } + + /// Reset the clip context. + #[inline] + pub fn reset(&mut self) { + self.clip_stack.clear(); + self.storage.clear(); + self.temp_storage.clear(); + } + + /// Get the data of the current clip path. + #[inline] + pub fn get(&self) -> Option> { + self.clip_stack + .last() + .map(|c| c.to_path_data_ref(&self.storage)) + } + + /// Push a new clip path to the stack. + #[inline] + pub fn push_clip( + &mut self, + clip_path: &BezPath, + strip_generator: &mut StripGenerator, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + ) { + self.temp_storage.clear(); + + let alpha_start = self.storage.alphas.len() as u32; + let strip_start = self.storage.strips.len() as u32; + + // Calculate a coarse bounding box of the path. If the path is empty, the bounding box is + // an infinite and inversed `kurbo::Rect`. This is harmless in practice: an empty path does + // not produce any strips. + // + // Note this iterates `clip_path`, which means we iterate it twice: once here, and once in + // flattening. If we ever take an iterator instead, or want to prevent iterating twice, we + // could move this calculation into flattening (perhaps with a const-generic as to not + // pessimize calls that don't require the bbox). + let path_bbox = control_point_bbox(clip_path, transform); + let mut bbox = RectU16::new( + path_bbox.x0 as u16, + path_bbox.y0 as u16, + path_bbox.x1.ceil() as u16, + path_bbox.y1.ceil() as u16, + ); + + // Intersect with the existing clip bounding box, or the viewport if this is the outermost + // clip. + if let Some(existing) = self.clip_stack.last() { + bbox = bbox.intersect(existing.bbox); + } else { + bbox.x1 = bbox.x1.min(strip_generator.width()); + bbox.y1 = bbox.y1.min(strip_generator.height()); + } + + let clip_data = ClipData { + alpha_start, + strip_start, + bbox, + }; + + let existing_clip = self + .clip_stack + .last() + .map(|c| c.to_path_data_ref(&self.storage)); + + strip_generator.generate_filled_path( + clip_path, + fill_rule, + transform, + aliasing_threshold, + &mut self.temp_storage, + existing_clip, + ); + + self.storage.extend(&self.temp_storage); + self.clip_stack.push(clip_data); + } + + /// Pop the least recent clip path. + #[inline] + pub fn pop_clip(&mut self) { + let data = self.clip_stack.pop().expect("clip stack underflowed"); + self.storage.strips.truncate(data.strip_start as usize); + self.storage.alphas.truncate(data.alpha_start as usize); + } +} + +/// Compute a conservative bounding box for the transformed path by computing the bounding box of +/// the transformed control points. +/// +/// If `path` is empty, this returns an infinite, inversed [`Rect`] (`left` > `right` and `top` > `bottom`). +fn control_point_bbox(path: &BezPath, transform: Affine) -> Rect { + // Start with an infinite, inversed rectangle. Adding the first point immediately collapses it + // without branching. + let mut bbox = Rect::new( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + for el in path.iter() { + match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => { + bbox = bbox.union_pt(transform * p); + } + PathEl::QuadTo(p1, p2) => { + bbox = bbox.union_pt(transform * p1); + bbox = bbox.union_pt(transform * p2); + } + PathEl::CurveTo(p1, p2, p3) => { + bbox = bbox.union_pt(transform * p1); + bbox = bbox.union_pt(transform * p2); + bbox = bbox.union_pt(transform * p3); + } + PathEl::ClosePath => {} + } + } + bbox +} + +/// Borrowed data of a stripped path. +#[derive(Clone, Copy, Debug)] +pub struct PathDataRef<'a> { + /// The strips. + pub strips: &'a [Strip], + /// The alpha buffer. + pub alphas: &'a [u8], + + /// A coarse bounding box of the clip path in pixel coordinates. + /// + /// These bounds have already been intersected with the viewport. + pub bbox: RectU16, +} + +/// Compute the sparse strips representation of a path that results +/// from intersecting the two input paths. This can be used to implement +/// clip paths. +pub fn intersect( + level: Level, + path_1: PathDataRef<'_>, + path_2: PathDataRef<'_>, + target: &mut StripStorage, +) { + dispatch!(level, simd => intersect_impl(simd, path_1, path_2, target)); +} + +// PdfCraft patch: rows walked by `intersect_impl` on this thread (tests only). +#[cfg(test)] +std::thread_local! { + static ROWS_WALKED: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +/// The implementation of the clipping algorithm using sparse strips. Conceptually, it is relatively +/// simple: We iterate over each strip and fill region of the two paths in lock step and determine +/// all overlaps between the two. For each overlap, we proceed depending on what kind of region +/// we have in the first path and the second one. +/// - In case we have two fill regions, the overlap region will also be filled. +/// - In case we have one strip and one fill region, the overlap region will copy the alpha mask of the strip region. +/// - Finally, if we have two strip regions, we combine the alpha masks of both. +/// - All regions that are not filled in either path are simply ignored. +/// +/// This is all that this method does. It just looks more complicated as the logic for iterating +/// in lock step is a bit tricky. +fn intersect_impl( + simd: S, + path_1: PathDataRef<'_>, + path_2: PathDataRef<'_>, + target: &mut StripStorage, +) { + // In case either path is empty, the clip path should be empty. + if path_1.strips.is_empty() || path_2.strips.is_empty() { + return; + } + + // Ignore any y values that are outside the bounding box of either of the two paths, as + // those are guaranteed to have neither fill nor strip regions. + // PdfCraft patch: start at the later first row. Above it one path has no strips, so no row + // there yields output, but the walk visited each of them, from the top of the clip, for every + // path drawn (on the A1 sample's full-page canvas, about half its strip rows per stroke). + let mut cur_y = path_1.strips[0].strip_y().max(path_2.strips[0].strip_y()); + let end_y = path_1.strips[path_1.strips.len() - 1] + .strip_y() + .min(path_2.strips[path_2.strips.len() - 1].strip_y()); + + let mut path_1_idx = 0; + let mut path_2_idx = 0; + let mut strip_state = None; + + // Iterate over each strip row and handle them. + while cur_y <= end_y { + // PdfCraft patch: counts the rows walked, for the test that pins where the walk starts. + #[cfg(test)] + ROWS_WALKED.with(|rows| rows.set(rows.get() + 1)); + // For each row, we create two iterators that alternatingly yield the strips and fill + // regions in that row, until the last strip has been reached. + let mut p1_iter = RowIterator::new(path_1, &mut path_1_idx, cur_y); + let mut p2_iter = RowIterator::new(path_2, &mut path_2_idx, cur_y); + + let mut p1_region = p1_iter.next(); + let mut p2_region = p2_iter.next(); + + // If at least one region is none, it means that we reached the end of the row + // for that path, meaning that we exceeded the bounding box of that path and no + // additional strips should be generated for that row, even if the other path might + // still have more strips left. They will all be clipped away. So only consider it + // if both paths have a region left. + while let (Some(region_1), Some(region_2)) = (p1_region, p2_region) { + match region_1.overlap_relationship(®ion_2) { + // This means there is no overlap between the regions, so we need to advance + // the iterator of the region that is further behind. + OverlapRelationship::Advance(advance) => { + match advance { + Advance::Left => p1_region = p1_iter.next(), + Advance::Right => p2_region = p2_iter.next(), + }; + + continue; + } + // We have an overlap! + OverlapRelationship::Overlap(overlap) => { + match (region_1, region_2) { + // Both regions are a fill. Flush the current strip and start a new + // one at the end of the overlap region setting `fill_gap` to true, + // so that the whole area before that will be filled with a sparse + // fill. + (Region::Fill(_), Region::Fill(_)) => { + flush_strip(&mut strip_state, &mut target.strips, cur_y); + start_strip(&mut strip_state, &target.alphas, overlap.end, true); + } + // One fill one strip, so we simply use the alpha mask from the strip region. + (Region::Strip(s), Region::Fill(_)) + | (Region::Fill(_), Region::Strip(s)) => { + // If possible, don't create a new strip but just extend the current one. + if should_create_new_strip(&strip_state, &target.alphas, overlap.start) + { + flush_strip(&mut strip_state, &mut target.strips, cur_y); + start_strip(&mut strip_state, &target.alphas, overlap.start, false); + } + + let s_alphas = &s.alphas[(overlap.start - s.start) as usize * 4..] + [..overlap.width() as usize * 4]; + target.alphas.extend_from_slice(s_alphas); + } + // Two strips, we need to multiply the opacity masks from both paths. + (Region::Strip(s_region_1), Region::Strip(s_region_2)) => { + // Once again, only create a new strip if we can't extend the current one. + if should_create_new_strip(&strip_state, &target.alphas, overlap.start) + { + flush_strip(&mut strip_state, &mut target.strips, cur_y); + start_strip(&mut strip_state, &target.alphas, overlap.start, false); + } + + let num_blocks = overlap.width() / Tile::HEIGHT; + + // Get the right alpha values for the specific position. + let s1_alphas = s_region_1.alphas + [(overlap.start - s_region_1.start) as usize * 4..] + .chunks_exact(16) + .take(num_blocks as usize); + let s2_alphas = s_region_2.alphas + [(overlap.start - s_region_2.start) as usize * 4..] + .chunks_exact(16) + .take(num_blocks as usize); + + for (s1_alpha, s2_alpha) in s1_alphas.zip(s2_alphas) { + let s1 = u8x16::from_slice(simd, s1_alpha); + let s2 = u8x16::from_slice(simd, s2_alpha); + + // Combine them. + let res = simd.narrow_u16x16(normalized_mul_u8x16(s1, s2)); + target.alphas.extend(res.as_slice()); + } + } + } + + // Advance the iterator of the path whose region's end is further behind. + match overlap.advance { + Advance::Left => p1_region = p1_iter.next(), + Advance::Right => p2_region = p2_iter.next(), + }; + } + } + } + + // Flush the strip before advancing to the next strip row. + flush_strip(&mut strip_state, &mut target.strips, cur_y); + cur_y += 1; + } + + // Push the sentinel strip, if one wasn't already pushed. + if !target.strips.last().is_some_and(Strip::is_sentinel) { + target.strips.push(Strip::new( + u16::MAX, + end_y * Tile::HEIGHT, + target.alphas.len() as u32, + false, + )); + } +} + +/// An overlap between two regions. +struct Overlap { + /// The start x coordinate. + start: u16, + /// The end x coordinate. + end: u16, + /// Whether the left or right region iterator should be advanced next. + advance: Advance, +} + +impl Overlap { + fn width(&self) -> u16 { + self.end - self.start + } +} + +enum Advance { + Left, + Right, +} + +/// The relationship between two regions. +enum OverlapRelationship { + /// There is no overlap between the regions, advance the region iterator on the given side. + Advance(Advance), + /// There is an overlap between the regions. + Overlap(Overlap), +} + +#[derive(Debug, Clone, Copy)] +struct FillRegion { + start: u16, + width: u16, +} + +#[derive(Debug, Clone, Copy)] +struct StripRegion<'a> { + start: u16, + width: u16, + alphas: &'a [u8], +} + +#[derive(Debug, Clone, Copy)] +enum Region<'a> { + Fill(FillRegion), + Strip(StripRegion<'a>), +} + +impl Region<'_> { + #[inline(always)] + fn start(&self) -> u16 { + match self { + Region::Fill(fill) => fill.start, + Region::Strip(strip) => strip.start, + } + } + + #[inline(always)] + fn width(&self) -> u16 { + match self { + Region::Fill(fill) => fill.width, + Region::Strip(strip) => strip.width, + } + } + + #[inline(always)] + fn end(&self) -> u16 { + self.start() + self.width() + } + + fn overlap_relationship(&self, other: &Region<'_>) -> OverlapRelationship { + if self.end() <= other.start() { + OverlapRelationship::Advance(Advance::Left) + } else if self.start() >= other.end() { + OverlapRelationship::Advance(Advance::Right) + } else { + let start = self.start().max(other.start()); + let end = self.end().min(other.end()); + + let shift = if self.end() <= other.end() { + Advance::Left + } else { + Advance::Right + }; + + OverlapRelationship::Overlap(Overlap { + advance: shift, + start, + end, + }) + } + } +} + +/// An iterator of strip and fill regions of a single strip row. +struct RowIterator<'a> { + /// The path in question. + input: PathDataRef<'a>, + /// The strip row we want to iterate over. + strip_y: u16, + /// The index of the current strip. + cur_idx: &'a mut usize, + /// Whether the iterator should yield a strip next or not. + /// When iterating over a row, we alternate between emitting strips and filled regions (unless + /// the region between two strips is not filled), so this flag acts as a toggle to store what + /// should be yielded next. + on_strip: bool, +} + +impl<'a> RowIterator<'a> { + fn new(input: PathDataRef<'a>, cur_idx: &'a mut usize, strip_y: u16) -> Self { + // Forward the index until we have found the right strip. + while input.strips[*cur_idx].strip_y() < strip_y { + *cur_idx += 1; + } + + Self { + input, + cur_idx, + strip_y, + on_strip: true, + } + } + + #[inline(always)] + fn cur_strip(&self) -> &Strip { + &self.input.strips[*self.cur_idx] + } + + #[inline(always)] + fn next_strip(&self) -> &Strip { + &self.input.strips[*self.cur_idx + 1] + } + + #[inline(always)] + fn cur_strip_width(&self) -> u16 { + let cur = self.cur_strip(); + let next = self.next_strip(); + ((next.alpha_idx() - cur.alpha_idx()) / Tile::HEIGHT as u32) as u16 + } + + #[inline(always)] + fn cur_strip_alphas(&self) -> &'a [u8] { + let cur = self.cur_strip(); + let next = self.next_strip(); + &self.input.alphas[cur.alpha_idx() as usize..next.alpha_idx() as usize] + } + + fn cur_strip_fill_area(&self) -> Option { + let next = self.next_strip(); + + // Note that if the next strip happens to be on the next line, it will always have + // zero winding so we don't need to special case this. + if next.fill_gap() { + let cur = self.cur_strip(); + let x = cur.x + self.cur_strip_width(); + let width = next.x - x; + + (width > 0).then_some(FillRegion { start: x, width }) + } else { + None + } + } +} + +impl<'a> Iterator for RowIterator<'a> { + type Item = Region<'a>; + + #[inline(always)] + fn next(&mut self) -> Option { + // If we are currently not on a strip, we want to yield a filled region in case there is one. + if !self.on_strip { + // Flip boolean flag so we will yield a strip in the next iteration. + self.on_strip = true; + + // if we have a filled area, yield it and return. Otherwise, do nothing and we will + // instead yield the next strip below. In any case, we need to advance the current index + // so that we point to the next strip now. + if let Some(fill_area) = self.cur_strip_fill_area() { + *self.cur_idx += 1; + + return Some(Region::Fill(fill_area)); + } else { + *self.cur_idx += 1; + } + } + + // If we reached this point, we will yield a strip this iteration, so toggle the flag + // so that in the next iteration, we yield a filled region instead. + self.on_strip = false; + + // If the current strip is sentinel or not within our target row, terminate. + if self.cur_strip().is_sentinel() || self.cur_strip().strip_y() != self.strip_y { + return None; + } + + // Calculate the dimensions of the strip and yield it. + let x = self.cur_strip().x; + let width = self.cur_strip_width(); + let alphas = self.cur_strip_alphas(); + + Some(Region::Strip(StripRegion { + start: x, + width, + alphas, + })) + } +} + +/// The data of the current strip we are building. +struct StripState { + x: u16, + alpha_idx: u32, + fill_gap: bool, +} + +fn flush_strip(strip_state: &mut Option, strips: &mut Vec, cur_y: u16) { + if let Some(state) = core::mem::take(strip_state) { + strips.push(Strip::new( + state.x, + cur_y * Tile::HEIGHT, + state.alpha_idx, + state.fill_gap, + )); + } +} + +#[inline(always)] +fn start_strip(strip_data: &mut Option, alphas: &[u8], x: u16, fill_gap: bool) { + *strip_data = Some(StripState { + x, + alpha_idx: alphas.len() as u32, + fill_gap, + }); +} + +fn should_create_new_strip( + strip_state: &Option, + alphas: &[u8], + overlap_start: u16, +) -> bool { + // Returns false in case we can append to the currently built strip. + strip_state.as_ref().is_none_or(|state| { + let width = ((alphas.len() as u32 - state.alpha_idx) / Tile::HEIGHT as u32) as u16; + let strip_end = state.x + width; + + strip_end < overlap_start - 1 + }) +} + +#[cfg(test)] +mod tests { + use crate::clip::{PathDataRef, Region, RowIterator, intersect}; + use crate::geometry::RectU16; + use crate::strip::Strip; + use crate::strip_generator::StripStorage; + use crate::tile::Tile; + use fearless_simd::Level; + use std::vec; + + #[test] + fn intersect_partly_overlapping_strips() { + let path_1 = StripBuilder::new().add_strip(0, 0, 32, false).finish(); + + let path_2 = StripBuilder::new().add_strip(8, 0, 44, false).finish(); + + let expected = StripBuilder::new().add_strip(8, 0, 32, false).finish(); + + run_test(expected, path_1, path_2); + } + + // PdfCraft patch: the walk starts at the first row both paths have strips in. Starting at the + // earlier first row walked every row between the top of the clip and this one for each path. + #[test] + fn intersect_walks_only_rows_both_paths_have_strips() { + let path_1 = StripBuilder::new().add_strip(0, 200, 8, false).finish(); + let path_2 = StripBuilder::new() + .add_strip(0, 0, 8, false) + .add_strip(0, 200, 8, false) + .finish(); + let expected = StripBuilder::new().add_strip(0, 200, 8, false).finish(); + + super::ROWS_WALKED.with(|rows| rows.set(0)); + run_test(expected, path_1, path_2); + let walked = super::ROWS_WALKED.with(core::cell::Cell::get); + assert_eq!(walked, 1, "the walk visited {walked} rows; it should visit only row 200"); + } + + #[test] + fn intersect_multiple_overlapping_strips() { + let path_1 = StripBuilder::new() + .add_strip(0, 1, 4, false) + .add_strip(12, 1, 20, true) + .add_strip(28, 1, 32, false) + .add_strip(44, 1, 52, true) + .finish(); + + let path_2 = StripBuilder::new() + .add_strip(4, 1, 8, false) + .add_strip(16, 1, 20, true) + .add_strip(24, 1, 28, false) + .add_strip(32, 1, 36, false) + .add_strip(44, 1, 48, true) + .finish(); + + let expected = StripBuilder::new() + .add_strip(4, 1, 8, false) + .add_strip(12, 1, 20, true) + .add_strip(32, 1, 36, false) + .add_strip(44, 1, 48, true) + .finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn multiple_rows() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(16, 0, 20, true) + .add_strip(4, 1, 8, false) + .add_strip(12, 1, 24, true) + .add_strip(4, 2, 8, false) + .add_strip(16, 2, 32, true) + .finish(); + + let path_2 = StripBuilder::new() + .add_strip(0, 2, 4, false) + .add_strip(16, 2, 24, true) + .add_strip(8, 3, 12, false) + .add_strip(16, 3, 28, true) + .finish(); + + let expected = StripBuilder::new() + .add_strip(4, 2, 8, false) + .add_strip(16, 2, 24, true) + .finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn alpha_buffer_correct_width() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(0, 1, 12, false) + .finish(); + + let path_2 = StripBuilder::new() + .add_strip(4, 0, 8, false) + .add_strip(0, 1, 4, false) + .add_strip(12, 1, 16, true) + .finish(); + + let expected = StripBuilder::new().add_strip(0, 1, 12, false).finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn row_iterator_abort_next_line() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(0, 1, 4, false) + .finish(); + + let path_ref = PathDataRef { + strips: &path_1.strips, + alphas: &path_1.alphas, + bbox: RectU16::new(0, 0, u16::MAX, u16::MAX), + }; + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert!(iter.next().is_some()); + assert!(iter.next().is_none()); + } + + #[test] + fn row_iterator_sentinel_fill_gap() { + let path = StripBuilder::new() + .add_strip(0, 0, Tile::WIDTH, false) + .finish_with_fill_gap_sentinel(); + let path_ref = path_ref(&path); + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, Tile::WIDTH); + assert_fill_region(iter.next(), Tile::WIDTH, u16::MAX - Tile::WIDTH); + assert!(iter.next().is_none()); + } + + #[test] + fn intersect_strip_with_sentinel_fill_gap() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, Tile::WIDTH, false) + .finish_with_fill_gap_sentinel(); + let path_2 = StripBuilder::new().add_strip(8, 0, 12, false).finish(); + let expected = StripBuilder::new().add_strip(8, 0, 12, false).finish(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn intersect_two_sentinel_fill_gaps() { + let path_1 = StripBuilder::new() + .add_strip(0, 0, 8, false) + .finish_with_fill_gap_sentinel(); + let path_2 = StripBuilder::new() + .add_strip(4, 0, 12, false) + .finish_with_fill_gap_sentinel(); + let expected = StripBuilder::new() + .add_strip(4, 0, 12, false) + .finish_with_fill_gap_sentinel(); + + run_test(expected, path_1, path_2); + } + + #[test] + fn row_iterator_fill_gap_stops_at_row_boundary() { + let mut path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .finish_with_fill_gap_sentinel(); + let idx = path.alphas.len(); + path.strips + .push(Strip::new(0, Tile::HEIGHT, idx as u32, false)); + path.alphas + .extend([0; Tile::HEIGHT as usize * Tile::WIDTH as usize]); + path.strips.push(Strip::new( + u16::MAX, + Tile::HEIGHT, + path.alphas.len() as u32, + false, + )); + + let path_ref = path_ref(&path); + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, 4); + assert_fill_region(iter.next(), 4, u16::MAX - 4); + assert!(iter.next().is_none()); + + let mut iter = RowIterator::new(path_ref, &mut idx, 1); + + assert_strip_region(iter.next(), 0, Tile::WIDTH); + assert!(iter.next().is_none()); + } + + #[test] + fn row_iterator_adjacent_unmerged_strips_no_fill() { + let path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(4, 0, 8, false) + .finish(); + let path_ref = path_ref(&path); + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, 4); + assert_strip_region(iter.next(), 4, 4); + assert!(iter.next().is_none()); + } + + #[test] + fn row_iterator_adjacent_unmerged_strips_with_fill_gap() { + let path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(4, 0, 8, true) + .finish(); + let path_ref = path_ref(&path); + + let mut idx = 0; + let mut iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_strip_region(iter.next(), 0, 4); + assert_strip_region(iter.next(), 4, 4); + assert!(iter.next().is_none()); + } + + #[test] + fn intersect_adjacent_unmerged_strips() { + let path = StripBuilder::new() + .add_strip(0, 0, 4, false) + .add_strip(4, 0, 8, true) + .finish(); + let cover = StripBuilder::new().add_strip(0, 0, 8, false).finish(); + let expected = StripBuilder::new().add_strip(0, 0, 8, false).finish(); + + run_test(expected, path, cover); + } + + #[test] + fn row_iterator_zero_width_alpha_region() { + let mut path = StripStorage::default(); + path.strips.push(Strip::new(8, 0, 0, false)); + path.strips.push(Strip::new(16, 0, 0, true)); + path.strips.push(Strip::new(u16::MAX, 0, 0, false)); + let path_ref = path_ref(&path); + + let mut idx = 0; + let iter = RowIterator::new(path_ref, &mut idx, 0); + + assert_eq!(iter.cur_strip_width(), 0); + let fill = iter.cur_strip_fill_area().unwrap(); + assert_eq!(fill.start, 8); + assert_eq!(fill.width, 8); + + let cover = StripBuilder::new().add_strip(0, 0, 20, false).finish(); + let expected = StripBuilder::new().add_strip(8, 0, 16, false).finish(); + + run_test(expected, path, cover); + } + + fn run_test(expected: StripStorage, path_1: StripStorage, path_2: StripStorage) { + let mut write_target = StripStorage::default(); + + let path_1 = path_ref(&path_1); + let path_2 = path_ref(&path_2); + + intersect(Level::new(), path_1, path_2, &mut write_target); + + assert_eq!(write_target, expected); + } + + fn path_ref(path: &StripStorage) -> PathDataRef<'_> { + PathDataRef { + strips: &path.strips, + alphas: &path.alphas, + bbox: RectU16::new(0, 0, u16::MAX, u16::MAX), + } + } + + fn assert_strip_region(region: Option>, start: u16, width: u16) { + match region { + Some(Region::Strip(strip)) => { + assert_eq!(strip.start, start); + assert_eq!(strip.width, width); + assert_eq!(strip.alphas.len(), (width * Tile::HEIGHT) as usize); + } + other => panic!("expected strip region, got {other:?}"), + } + } + + fn assert_fill_region(region: Option>, start: u16, width: u16) { + match region { + Some(Region::Fill(fill)) => { + assert_eq!(fill.start, start); + assert_eq!(fill.width, width); + } + other => panic!("expected fill region, got {other:?}"), + } + } + + struct StripBuilder { + storage: StripStorage, + } + + impl StripBuilder { + fn new() -> Self { + Self { + storage: StripStorage::default(), + } + } + + fn add_strip(self, x: u16, strip_y: u16, end: u16, fill_gap: bool) -> Self { + let width = end - x; + self.add_strip_with( + x, + strip_y, + end, + fill_gap, + &vec![0; (width * Tile::HEIGHT) as usize], + ) + } + + fn add_strip_with( + mut self, + x: u16, + strip_y: u16, + end: u16, + fill_gap: bool, + alphas: &[u8], + ) -> Self { + let width = end - x; + assert_eq!(alphas.len(), (width * Tile::HEIGHT) as usize); + let idx = self.storage.alphas.len(); + self.storage + .strips + .push(Strip::new(x, strip_y * Tile::HEIGHT, idx as u32, fill_gap)); + self.storage.alphas.extend_from_slice(alphas); + + self + } + + fn finish(mut self) -> StripStorage { + let last_y = self.storage.strips.last().unwrap().y; + let idx = self.storage.alphas.len(); + + self.storage + .strips + .push(Strip::new(u16::MAX, last_y, idx as u32, false)); + + self.storage + } + + fn finish_with_fill_gap_sentinel(mut self) -> StripStorage { + let last_y = self.storage.strips.last().unwrap().y; + let idx = self.storage.alphas.len(); + + self.storage + .strips + .push(Strip::new(u16::MAX, last_y, idx as u32, true)); + + self.storage + } + } +} diff --git a/vendor/vello_common/src/coarse.rs b/vendor/vello_common/src/coarse.rs new file mode 100644 index 0000000000..14f9b3a9ac --- /dev/null +++ b/vendor/vello_common/src/coarse.rs @@ -0,0 +1,2385 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Generating and processing wide tiles. + +use crate::color::palette::css::TRANSPARENT; +use crate::encode::EncodedPaint; +use crate::filter_effects::Filter; +use crate::geometry::RectU16; +use crate::kurbo::{Affine, Rect}; +use crate::mask::Mask; +use crate::paint::{Paint, PremulColor}; +use crate::peniko::{BlendMode, Compose, Mix}; +use crate::render_graph::{DependencyKind, LayerId, RenderGraph, RenderNodeKind}; +use crate::{strip::Strip, tile::Tile}; +use alloc::vec; +use alloc::{boxed::Box, vec::Vec}; +#[cfg(debug_assertions)] +use alloc::{format, string::String}; +use core::ops::Range; +use hashbrown::HashMap; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +#[derive(Debug)] +struct Layer { + /// The layer's ID. + layer_id: LayerId, + /// Whether the layer has a clip associated with it. + clip: bool, + /// The blend mode with which this layer should be blended into + /// the previous layer. + blend_mode: BlendMode, + /// An opacity to apply to the whole layer before blending it + /// into the backdrop. + opacity: f32, + /// A mask to apply to the layer before blending it back into + /// the backdrop. + mask: Option, + /// A filter effect to apply to the layer before other operations. + filter: Option, + /// Bounding box of wide tiles containing geometry. + /// Starts with inverted bounds, shrinks to actual content during drawing. + wtile_bbox: WideTilesBbox, +} + +impl Layer { + /// Whether the layer actually requires allocating a new scratch buffer + /// for drawing its contents. + fn needs_buf(&self) -> bool { + self.blend_mode.mix != Mix::Normal + || self.blend_mode.compose != Compose::SrcOver + || self.opacity != 1.0 + || self.mask.is_some() + || self.filter.is_some() + || !self.clip + } +} + +/// `MODE_CPU` allows compile time optimizations to be applied to wide tile draw command generation +/// specific to `vello_cpu`. +pub const MODE_CPU: u8 = 0; +/// `MODE_HYBRID` allows compile time optimizations to be applied to wide tile draw command +/// generation specific for `vello_hybrid`. +pub const MODE_HYBRID: u8 = 1; + +/// The index that in `push_buf_indices` is reserved for indicating the target is the final +/// destination surface. +const TARGET_SURFACE_PUSH_BUF_IDX: usize = usize::MAX; + +#[derive(Debug)] +struct LayerKindAndOccupiedTiles { + /// The kind of layer needing a scratch buffer. This is tracked to allow lazy buffer pushing to + /// know for which layer kind to push. + kind: LayerKind, + /// The indices into [`Wide::tiles`] of wide tiles that occupy this layer. These are the wide + /// tiles that have pushed a buffer and need to be popped when the layer is popped. + occupied_tiles: Vec, +} + +/// For layers that require allocating a new scratch buffer for drawing its contents, this stores +/// the layer kind and the occupied wide tiles. +/// +/// The wide tile push buffer command [`WideTile::push_buf`] is performed lazily upon actually +/// being drawn into. +/// +/// See [`LayerKindAndOccupiedTiles`]. +/// +/// This is a small wrapper around [`Vec`] so we can keep the +/// [`LayerKindAndOccupiedTiles::occupied_tiles`] allocations around. +#[derive(Debug, Default)] +struct NeedsBufLayerStack { + stack: Vec, + /// The actual length of [`Self::stack`], as we never actually shrink it (see the note above + /// about keeping allocations around). + len: usize, +} + +impl NeedsBufLayerStack { + #[inline] + fn clear(&mut self) { + self.len = 0; + } + + #[inline] + fn push(&mut self, kind: LayerKind) { + if self.len == self.stack.len() { + self.stack.push(LayerKindAndOccupiedTiles { + kind, + occupied_tiles: vec![], + }); + } else { + self.stack[self.len].occupied_tiles.clear(); + self.stack[self.len].kind = kind; + } + self.len += 1; + } + + #[inline] + fn pop(&mut self) { + debug_assert!(self.len > 0, "Called `pop` at the root"); + self.len -= 1; + } + + #[inline] + fn last(&self) -> Option<&LayerKindAndOccupiedTiles> { + self.len.checked_sub(1).map(|idx| &self.stack[idx]) + } +} + +/// A container for wide tiles. +#[derive(Debug)] +pub struct Wide { + /// The width of the container. + width: u16, + /// The height of the container. + height: u16, + /// The wide tiles in the container. + tiles: Vec>, + /// Shared command properties, referenced by index from fill and clip commands. + pub attrs: CommandAttrs, + /// The stack of layers. + layer_stack: Vec, + /// The stack of layer kinds and occupied tiles for layers that require buffers. + /// + /// This contains exactly one entry for each layer with [`Layer::needs_buf`] in + /// [`Self::layer_stack`]. + layers_needing_buf_stack: NeedsBufLayerStack, + /// The stack of active clip regions. + clip_stack: Vec, + /// Stack of filter layer node IDs for render graph dependency tracking. + /// Initialized with node 0 (the root node representing the final output). + /// As layers with filters are pushed, their node IDs are added to this stack. + filter_node_stack: Vec, + /// Count of nested filtered layers with clip paths. + /// When > 0, command generation uses full viewport bounds instead of clip bounds + /// to ensure filter effects can process the full layer before applying the clip. + clipped_filter_layer_depth: u32, + /// Global batch counter, incremented each time we transition from fast strip rendering + /// to a set of strips passing through coarse rasterization. + batch_count: u32, + /// Whether to enable the optimization that allows fills with an opaque color to clear + /// all previous commands if it spans the whole wide tile. + /// + /// This needs to be disabled for the interleaved rendering path in `vello_hybrid`, because + /// the background is applied as the very first operation, and we have no way of clearing + /// strips in the fast path that would affect the area of the wide tile. + enable_bg_optimization: bool, + /// Whether at least one of the wide tiles has been mutated and thus they + /// need to be reset. + tiles_dirty: bool, +} + +/// A clip region. +#[derive(Debug)] +struct Clip { + /// The intersected bounding box after clip + pub clip_bbox: WideTilesBbox, + /// The rendered path in sparse strip representation + pub strips: Box<[Strip]>, + /// The index of the thread that owns the alpha buffer. + /// Always 0 in single-threaded mode. + pub thread_idx: u8, +} + +/// An axis-aligned bounding box in wide tile coordinates. +/// +/// This is a wrapper around [`RectU16`], adding some utilities useful for converting between +/// wide-tile space and pixel-space. `(x0, y0)` is the top-left corner and `(x1, y1)` is the +/// bottom-right corner, both in wide tile units. +#[derive(Debug, Clone, Copy)] +pub struct WideTilesBbox { + /// The bounding box. + bbox: RectU16, +} + +impl WideTilesBbox { + /// A empty rectangle with all coordinates set to zero. + pub const ZERO: Self = Self { + bbox: RectU16::ZERO, + }; + + /// An empty, maximally inverted rectangle, useful as a starting value for incremental union + /// operations. + /// + /// Has `(x0, y0) = (u16::MAX, u16::MAX)` and `(x1, y1) = (0, 0)`. + const INVERTED: Self = Self { + bbox: RectU16::INVERTED, + }; + + /// Create a new bounding box from wide tile coordinates. + pub fn new(x0: u16, y0: u16, x1: u16, y1: u16) -> Self { + Self { + bbox: RectU16::new(x0, y0, x1, y1), + } + } + + /// Get the x0 coordinate of the bounding box. + #[inline(always)] + pub fn x0(&self) -> u16 { + self.bbox.x0 + } + + /// Get the y0 coordinate of the bounding box. + #[inline(always)] + pub fn y0(&self) -> u16 { + self.bbox.y0 + } + + /// Get the x1 coordinate of the bounding box. + #[inline(always)] + pub fn x1(&self) -> u16 { + self.bbox.x1 + } + + /// Get the y1 coordinate of the bounding box. + #[inline(always)] + pub fn y1(&self) -> u16 { + self.bbox.y1 + } + + /// Returns `true` if the bounding box has zero area. + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.bbox.is_empty() + } + + /// Get the width of the bounding box in tiles (x1 - x0). + #[inline(always)] + pub fn width_tiles(&self) -> u16 { + self.bbox.width() + } + + /// Get the width of the bounding box in pixels. + #[inline(always)] + pub fn width_px(&self) -> u16 { + self.width_tiles() * WideTile::WIDTH + } + + /// Get the height of the bounding box in tiles (y1 - y0). + #[inline(always)] + pub fn height_tiles(&self) -> u16 { + self.bbox.height() + } + + /// Get the height of the bounding box in pixels. + #[inline(always)] + pub fn height_px(&self) -> u16 { + self.height_tiles() * Tile::HEIGHT + } + + /// Check if a point (x, y) is contained within this bounding box. + /// + /// Returns `true` if x0 <= x < x1 and y0 <= y < y1. + #[inline(always)] + pub fn contains(&self, x: u16, y: u16) -> bool { + self.bbox.contains(x, y) + } + + /// Calculate the intersection of this bounding box with another. + #[inline(always)] + pub(crate) fn intersect(self, other: Self) -> Self { + Self { + bbox: self.bbox.intersect(other.bbox), + } + } + + /// Update this bounding box to include another bounding box (union in place). + #[inline(always)] + pub(crate) fn union(&mut self, other: Self) { + self.bbox.union(other.bbox); + } + + /// Update the bbox to include the given tile coordinates. + #[inline(always)] + pub(crate) fn include_tile(&mut self, wtile_x: u16, wtile_y: u16) { + self.bbox.x0 = self.bbox.x0.min(wtile_x); + self.bbox.y0 = self.bbox.y0.min(wtile_y); + self.bbox.x1 = self.bbox.x1.max(wtile_x + 1); + self.bbox.y1 = self.bbox.y1.max(wtile_y + 1); + } + + /// Scale this bounding box by the given scale factors. + /// + /// Multiplies each coordinate by the corresponding scale factor to convert + /// from one coordinate system to another. + #[inline(always)] + pub fn scale(&self, scale_x: u16, scale_y: u16) -> [u32; 4] { + [ + u32::from(self.x0()) * u32::from(scale_x), + u32::from(self.y0()) * u32::from(scale_y), + u32::from(self.x1()) * u32::from(scale_x), + u32::from(self.y1()) * u32::from(scale_y), + ] + } + + /// Expands the bounding box outward by the given pixel amounts in each direction. + /// + /// Pixel values are converted to tile coordinates (rounding up) and clamped to the + /// valid range `[0, max_x)` × `[0, max_y)`. The result is a new bounding box in + /// wide tile coordinates. + pub fn expand_by_pixels(&self, expansion: Rect, max_x: u16, max_y: u16) -> Self { + // The expansion rect is centered at origin: + // - Negative coordinates (x0, y0) represent left/top expansion + // - Positive coordinates (x1, y1) represent right/bottom expansion + let left_px = (-expansion.x0).max(0.0).ceil() as u16; + let top_px = (-expansion.y0).max(0.0).ceil() as u16; + let right_px = expansion.x1.max(0.0).ceil() as u16; + let bottom_px = expansion.y1.max(0.0).ceil() as u16; + + // Convert pixel expansion to tile expansion (round up) + let left_tiles = left_px.div_ceil(WideTile::WIDTH); + let top_tiles = top_px.div_ceil(Tile::HEIGHT); + let right_tiles = right_px.div_ceil(WideTile::WIDTH); + let bottom_tiles = bottom_px.div_ceil(Tile::HEIGHT); + + Self::new( + self.x0().saturating_sub(left_tiles), + self.y0().saturating_sub(top_tiles), + (self.x1() + right_tiles).min(max_x), + (self.y1() + bottom_tiles).min(max_y), + ) + } +} + +impl Wide { + /// Create a new container for wide tiles. + pub fn new(width: u16, height: u16) -> Self { + Self::new_internal(width, height, true) + } +} + +impl Wide { + /// Create a new container for wide tiles. + pub fn new(width: u16, height: u16, enable_bg_optimization: bool) -> Self { + Self::new_internal(width, height, enable_bg_optimization) + } + + /// Record a coarse batch boundary. + /// + /// Each tile lazily emits the pending `BatchEnd` markers the next + /// time it receives a command. + #[inline(always)] + pub fn end_batch(&mut self) { + self.batch_count += 1; + } +} + +impl Wide { + /// Create a new container for wide tiles. + fn new_internal(width: u16, height: u16, enable_bg_optimization: bool) -> Self { + let width_tiles = width.div_ceil(WideTile::WIDTH); + let height_tiles = height.div_ceil(Tile::HEIGHT); + let mut tiles = Vec::with_capacity(usize::from(width_tiles) * usize::from(height_tiles)); + + for h in 0..height_tiles { + for w in 0..width_tiles { + tiles.push(WideTile::::new_internal( + w * WideTile::WIDTH, + h * Tile::HEIGHT, + )); + } + } + + Self { + tiles, + width, + height, + attrs: CommandAttrs::default(), + enable_bg_optimization, + layer_stack: vec![], + clip_stack: vec![], + // Start with root node 0. + filter_node_stack: vec![0], + clipped_filter_layer_depth: 0, + layers_needing_buf_stack: NeedsBufLayerStack::default(), + batch_count: 0, + tiles_dirty: false, + } + } + + /// Whether there are any existing layers that haven't been popped yet. + pub fn has_layers(&self) -> bool { + !self.layer_stack.is_empty() + } + + /// Reset all tiles in the container. + pub fn reset(&mut self) { + if self.tiles_dirty { + for tile in &mut self.tiles { + tile.reset(); + } + + self.tiles_dirty = false; + } + + self.attrs.clear(); + self.layer_stack.clear(); + self.layers_needing_buf_stack.clear(); + self.clip_stack.clear(); + self.filter_node_stack.truncate(1); + self.clipped_filter_layer_depth = 0; + self.batch_count = 0; + } + + /// Return the number of horizontal tiles. + pub fn width_tiles(&self) -> u16 { + self.width.div_ceil(WideTile::WIDTH) + } + + /// Return the number of vertical tiles. + pub fn height_tiles(&self) -> u16 { + self.height.div_ceil(Tile::HEIGHT) + } + + /// Get the index of the wide tile at the given coordinates. + /// + /// Panics if the coordinates are out-of-range. + #[inline] + pub fn get_idx(&self, x: u16, y: u16) -> usize { + assert!( + x < self.width_tiles() && y < self.height_tiles(), + "attempted to access out-of-bounds wide tile" + ); + + usize::from(y) * usize::from(self.width_tiles()) + usize::from(x) + } + + /// Get the index of the wide tile at the given coordinates. + /// + /// Panics if the coordinates are out-of-range. + pub fn get(&self, x: u16, y: u16) -> &WideTile { + let idx = self.get_idx(x, y); + &self.tiles[idx] + } + + /// Get mutable access to the wide tile at the given coordinates. + /// + /// Panics if the coordinates are out-of-range. + fn get_mut(&mut self, x: u16, y: u16) -> &mut WideTile { + let idx = self.get_idx(x, y); + &mut self.tiles[idx] + } + + /// Return a reference to all wide tiles. + pub fn tiles(&self) -> &[WideTile] { + self.tiles.as_slice() + } + + /// Get the current layer Id. + #[inline(always)] + pub fn get_current_layer_id(&self) -> LayerId { + self.layer_stack.last().map_or(0, |l| l.layer_id) + } + + /// Update the bounding box of the current layer to include the given tile. + /// Should be called whenever a command is generated for a tile. + #[inline] + fn update_current_layer_bbox(&mut self, wtile_x: u16, wtile_y: u16) { + if let Some(layer) = self.layer_stack.last_mut() { + layer.wtile_bbox.include_tile(wtile_x, wtile_y); + } + } + + /// Generate wide tile commands from the strip buffer. + /// + /// This method processes a buffer of strips that represent a path, applies the fill rule, + /// and generates appropriate drawing commands for each affected wide tile. + /// + /// # Algorithm overview: + /// 1. For each strip in the buffer: + /// - Calculate its position and width in pixels + /// - Determine which wide tiles the strip intersects + /// - Generate alpha fill commands for the intersected wide tiles + /// 2. For active fill regions (determined by fill rule): + /// - Generate solid fill commands for the regions between strips + pub fn generate( + &mut self, + strip_buf: &[Strip], + paint: Paint, + blend_mode: BlendMode, + thread_idx: u8, + mask: Option, + encoded_paints: &[EncodedPaint], + ) { + if strip_buf.is_empty() { + return; + } + + self.tiles_dirty = true; + let alpha_base_idx = strip_buf[0].alpha_idx(); + + // Create shared attributes for all commands from this path + let attrs_idx = self.attrs.fill.len() as u32; + self.attrs.fill.push(FillAttrs { + thread_idx, + paint, + blend_mode, + mask, + alpha_base_idx, + }); + + // Get current clip bounding box or full viewport if no clip is active + let bbox = self.active_bbox(); + + // Save current_layer_id and batch_count to avoid borrowing issues + let current_layer_id = self.get_current_layer_id(); + let batch_count = self.batch_count; + + for i in 0..strip_buf.len() - 1 { + let strip = &strip_buf[i]; + + debug_assert!( + strip.y < self.height, + "Strips below the viewport should have been culled prior to this stage." + ); + + // Don't render strips that are outside the viewport width + if strip.x >= self.width { + continue; + } + + let next_strip = &strip_buf[i + 1]; + let x0 = strip.x; + let strip_y = strip.strip_y(); + + // Skip strips outside the current clip bounding box + if strip_y < bbox.y0() { + continue; + } + if strip_y >= bbox.y1() { + // The rest of our strips must be outside the clip, so we can break early. + break; + } + + // Calculate the width of the strip in columns + let mut col = strip.alpha_idx() / u32::from(Tile::HEIGHT); + let next_col = next_strip.alpha_idx() / u32::from(Tile::HEIGHT); + // Can potentially be 0 if strip only changes winding without covering pixels + let strip_width = next_col.saturating_sub(col) as u16; + let x1 = x0.saturating_add(strip_width); + + // Calculate which wide tiles this strip intersects + let wtile_x0 = (x0 / WideTile::WIDTH).max(bbox.x0()); + // It's possible that a strip extends into a new wide tile, but we don't actually + // have as many wide tiles (e.g. because the pixmap width is only 512, but + // strip ends at 513), so take the minimum between the rounded values and `width_tiles`. + let wtile_x1 = x1 + .div_ceil(WideTile::WIDTH) + .min(bbox.x1()) + .min(WideTile::MAX_WIDE_TILE_COORD); + + // Adjust column starting position if needed to respect clip boundaries + let mut x = x0; + let clip_x = bbox.x0() * WideTile::WIDTH; + if clip_x > x { + col += u32::from(clip_x - x); + x = clip_x; + } + + // Generate alpha fill commands for each wide tile intersected by this strip + for wtile_x in wtile_x0..wtile_x1 { + let x_wtile_rel = x % WideTile::WIDTH; + // Restrict the width of the fill to the width of the wide tile + let width = x1.min((wtile_x + 1) * WideTile::WIDTH) - x; + let cmd = CmdAlphaFill { + x: x_wtile_rel, + width, + alpha_offset: col * u32::from(Tile::HEIGHT) - alpha_base_idx, + attrs_idx, + }; + x += width; + col += u32::from(width); + let idx = self.get_idx(wtile_x, strip_y); + self.tiles[idx].strip( + batch_count, + idx, + &mut self.layers_needing_buf_stack, + cmd, + current_layer_id, + ); + self.update_current_layer_bbox(wtile_x, strip_y); + } + + // Determine if the region between this strip and the next should be filled. + let active_fill = next_strip.fill_gap(); + + // If region should be filled and both strips are on the same row, + // generate fill commands for the region between them + if active_fill && strip_y == next_strip.strip_y() { + // Clamp the fill to the clip bounding box + x = x1.max(bbox.x0() * WideTile::WIDTH); + let x2 = next_strip.x.min( + self.width + .checked_next_multiple_of(WideTile::WIDTH) + .unwrap_or(u16::MAX), + ); + let wfxt0 = (x1 / WideTile::WIDTH).max(bbox.x0()); + let wfxt1 = x2 + .div_ceil(WideTile::WIDTH) + .min(bbox.x1()) + .min(WideTile::MAX_WIDE_TILE_COORD); + + // Compute fill hint based on paint type + let fill_attrs = &self.attrs.fill[attrs_idx as usize]; + let fill_hint = if fill_attrs.mask.is_none() && self.enable_bg_optimization { + match &fill_attrs.paint { + Paint::Solid(s) if s.is_opaque() => FillHint::OpaqueSolid(*s), + Paint::Indexed(idx) => match encoded_paints.get(idx.index()) { + Some(EncodedPaint::Image(img)) + if !img.may_have_transparency + && img.sampler.alpha == 1.0 + && img.tint.is_none_or(|t| t.color.components[3] >= 1.0) => + { + FillHint::OpaqueImage + } + Some(EncodedPaint::ExternalTexture(img)) + if !img.may_have_transparency + && img.sampler.alpha == 1.0 + && img.tint.is_none_or(|t| t.color.components[3] >= 1.0) => + { + FillHint::OpaqueImage + } + _ => FillHint::None, + }, + _ => FillHint::None, + } + } else { + FillHint::None + }; + + // Generate fill commands for each wide tile in the fill region + for wtile_x in wfxt0..wfxt1 { + let x_wtile_rel = x % WideTile::WIDTH; + let width = x2.min( + (wtile_x + .checked_add(1) + .unwrap_or(WideTile::MAX_WIDE_TILE_COORD)) + * WideTile::WIDTH, + ) - x; + x += width; + let idx = self.get_idx(wtile_x, strip_y); + self.tiles[idx].fill( + batch_count, + idx, + &mut self.layers_needing_buf_stack, + x_wtile_rel, + width, + attrs_idx, + current_layer_id, + fill_hint, + ); + // TODO: This bbox update might be redundant since filled regions are always + // bounded by strip regions (which already update the bbox). Consider removing + // this in a follow-up with proper benchmarks to verify correctness. + self.update_current_layer_bbox(wtile_x, strip_y); + } + } + } + } + + /// Push a new layer with the given properties. + /// + /// Rendering will be directed to the layer storage identified by `layer_id`. + /// This is used for filter effects that require access to a fully-rendered layer. + /// + /// If `filter` is Some, builds render graph nodes for filter effects. + pub fn push_layer( + &mut self, + layer_id: LayerId, + clip_path: Option>>, + blend_mode: BlendMode, + mask: Option, + opacity: f32, + filter: Option, + transform: Affine, + render_graph: &mut RenderGraph, + thread_idx: u8, + ) { + self.tiles_dirty = true; + + // Some explanations about what is going on here: We support the concept of + // layers, where a user can push a new layer (with certain properties), draw some + // stuff, and finally pop the layer, as part of which the layer as a whole will be + // blended into the previous layer. + // There are 3 "straightforward" properties that can be set for each layer: + // 1) The blend mode that should be used to blend the layer into the backdrop. + // 2) A mask that will be applied to the whole layer in the very end before blending. + // 3) An optional opacity that will be applied to the whole layer before blending (this + // could in theory be simulated with an alpha mask, but since it's a common operation and + // we only have a single opacity, this can easily be optimized. + // + // Finally, you can also add a clip path to the layer. However, clipping has its own + // more complicated logic for pushing/popping buffers where drawing is also suppressed + // in clipped-out wide tiles. Because of this, in case we have one of the above properties + // AND a clipping path, we will actually end up pushing two buffers, the first one handles + // the three properties and the second one is just for clip paths. That is a bit wasteful + // and I believe it should be possible to process them all in just one go, but for now + // this is good enough, and it allows us to implement blending without too deep changes to + // the original clipping implementation. + + // Build render graph node ONLY if we have a filter. + // The render graph tracks dependencies and execution order for filter effects. + if let Some(filter) = &filter { + // Create a FilterLayer node that combines render + filter + other operations + let child_node = render_graph.add_node(RenderNodeKind::FilterLayer { + layer_id, + filter: filter.clone(), + // Bounding box starts inverted and will be updated in pop_layer with actual bounds + wtile_bbox: WideTilesBbox::INVERTED, + transform, + }); + + // Connect to parent node if there is one + if let Some(&parent_node) = self.filter_node_stack.last() { + render_graph.add_edge( + child_node, + parent_node, + DependencyKind::Sequential { layer_id }, + ); + } + + // Push this filter node onto the stack so subsequent filters depend on it + self.filter_node_stack.push(child_node); + } + + let has_filter = filter.is_some(); + let has_clip = clip_path.is_some(); + let layer_kind = if has_filter { + LayerKind::Filtered(layer_id) + } else { + LayerKind::Regular(layer_id) + }; + + // Filtered layers with clipping require special handling: normally, tiles with + // zero-winding clips suppress all drawing. However, filters need the full layer + // content rendered (including zero-clipped areas) before applying the clip as a mask. + // When this flag is true, we generate explicit drawing commands instead of just counters. + let in_clipped_filter_layer = has_filter && has_clip; + + // Increment the depth counter so that active_bbox() returns the full viewport + // instead of the clipped bbox. This ensures command generation covers all tiles, + // allowing the filter to process the entire layer before the clip is applied. + if in_clipped_filter_layer { + self.clipped_filter_layer_depth += 1; + } + + let layer = Layer { + layer_id, + clip: has_clip, + blend_mode, + opacity, + mask, + filter, + wtile_bbox: WideTilesBbox::INVERTED, + }; + + // In case we do blending, masking, opacity, or filtering, push one buffer per wide tile. + // + // Layers require buffers for different reasons: + // - Blending, opacity, and masking: Need to composite results with the backdrop + // - Filtering: Content must be rendered to a buffer before applying filter effects + // + // The layer_kind parameter distinguishes how buffers are managed: + // - Regular layers use the local blend_buf stack + // - Filtered layers are materialized in persistent layer storage for filter processing + // - Clip layers have special handling for clipping operations + if layer.needs_buf() { + let batch_count = self.batch_count; + + self.layers_needing_buf_stack.push(layer_kind); + + // We eagerly push buffers for the current active bbox if this is a destructive blend + // or the entire viewport if we are (nested in) a clipped filter layer. + // + // TODO: We may be able to do away with some or all of this eager pushing in the + // future, but for now these types of layers need buffers for all wide tiles regardless + // of whether they're drawn on. + if layer.blend_mode.is_destructive() || self.clipped_filter_layer_depth > 0 { + let active_bbox = if self.clipped_filter_layer_depth > 0 { + self.full_viewport_bbox() + } else { + self.active_bbox() + }; + + for y in active_bbox.y0()..active_bbox.y1() { + for x in active_bbox.x0()..active_bbox.x1() { + let idx = self.get_idx(x, y); + self.tiles[idx].ensure_layer_stack_bufs( + idx, + &mut self.layers_needing_buf_stack, + batch_count, + ); + // Mark tiles that are in a clipped filter layer so they generate + // explicit clip commands for proper filter processing. + self.tiles[idx].in_clipped_filter_layer = in_clipped_filter_layer; + } + } + } + } + + // If we have a clip path, push another buffer in the affected wide tiles. + // Note that it is important that we FIRST push the buffer for blending etc. and + // only then for clipping, otherwise we will use the empty clip buffer as the backdrop + // for blending! + if let Some(clip) = clip_path { + self.push_clip(clip, layer_id, thread_idx); + } + + self.layer_stack.push(layer); + } + + /// Pop a previously pushed layer. + /// + /// This method finalizes the layer by: + /// - Expanding the bounding box if filter effects are present + /// - Updating the parent layer's bounding box to include this layer's bounds + /// - Completing render graph nodes for filter effects + /// - Generating filter commands for each tile + /// - Popping any associated clip + /// - Applying mask, opacity, and blend mode operations if needed + pub fn pop_layer(&mut self, render_graph: &mut RenderGraph) { + self.tiles_dirty = true; + + // This method basically unwinds everything we did in `push_layer`. + let mut layer = self.layer_stack.pop().unwrap(); + let batch_count = self.batch_count; + + if let Some(filter) = &layer.filter { + let mut final_bbox = WideTilesBbox::INVERTED; + + // Update render graph node with final bounding box + if let Some(node_id) = self.filter_node_stack.pop() { + // Get the transform from the FilterLayer node and scale the expansion by it + if let Some(node) = render_graph.nodes.get_mut(node_id) + && let RenderNodeKind::FilterLayer { + wtile_bbox, + transform, + .. + } = &mut node.kind + { + // Calculate expansion in device/pixel space, accounting for the full transform. + // This ensures that rotated filters (e.g., drop shadows) have correct bounds. + let expansion = filter.bounds_expansion(transform); + let expanded_bbox = layer.wtile_bbox.expand_by_pixels( + expansion, + self.width_tiles(), + self.height_tiles(), + ); + let clip_bbox = self.active_bbox(); + final_bbox = expanded_bbox.intersect(clip_bbox); + + // Update both the local layer and the render graph node + layer.wtile_bbox = final_bbox; + *wtile_bbox = final_bbox; + } + // Record this node in execution order (children before parents) + render_graph.record_node_for_execution(node_id); + } + + // Generate filter commands for each tile (used for non-graph path rendering) + // Apply filter BEFORE clipping (per SVG spec: filter → clip → mask → opacity → blend) + // Also ensure that each wide tile in the filter bbox (out of which some might not + // have been drawn on) has a `PushBuf` command. + for x in final_bbox.x0()..final_bbox.x1() { + for y in final_bbox.y0()..final_bbox.y1() { + let idx = self.get_idx(x, y); + self.tiles[idx].ensure_layer_stack_bufs( + idx, + &mut self.layers_needing_buf_stack, + batch_count, + ); + + // Note that unlike commands like "mask" or "opacity" which only need to be applied + // to wide tiles with drawing commands, filter commands always need to be applied + // to all wide tiles in the filter bounding-box, because it's possible that after + // applying a filter (like gaussian blur), wide tiles will take on a new color + // even though nothing had been drawn there originally. + self.tiles[idx].filter(layer.layer_id, filter.clone()); + } + } + } + + // Union this layer's bbox into the parent layer's bbox. + // This ensures the parent knows about all tiles used by this child layer, + // which is important for filter effects that may expand beyond the original content bounds. + if let Some(parent_layer) = self.layer_stack.last_mut() { + parent_layer.wtile_bbox.union(layer.wtile_bbox); + } + + if layer.clip { + self.pop_clip(); + } + + if layer.needs_buf() { + for &tile_idx in self + .layers_needing_buf_stack + .last() + .unwrap() + .occupied_tiles + .iter() + { + let tile = &mut self.tiles[tile_idx]; + + // Optimization: If no drawing happened since the last `PushBuf`, then we don't + // need to do any masking or buffer-wide opacity work. Even though we push buffers + // lazily, this can still happen: e.g., filter layers with clips and destructive blends + // currently push layers eagerly. + let has_draw_commands = !matches!(tile.cmds.last().unwrap(), &Cmd::PushBuf(..)); + if has_draw_commands { + if let Some(mask) = layer.mask.clone() { + tile.mask(mask); + } + tile.opacity(layer.opacity); + } + // We only need to blend if there are draw commands, unless this is destructive + // blending, in which case we always blend. + if has_draw_commands || layer.blend_mode.is_destructive() { + tile.blend(layer.blend_mode); + } + tile.pop_buf(); + } + self.layers_needing_buf_stack.pop(); + } + + let in_clipped_filter_layer = layer.filter.is_some() && layer.clip; + // Decrement the depth counter after popping a filtered layer with clip + if in_clipped_filter_layer { + self.clipped_filter_layer_depth -= 1; + } + } + + /// Adds a clipping region defined by the provided strips. + /// + /// This method takes a vector of strips representing a clip path, calculates the + /// intersection with the current clip region, and updates the clip stack. + /// + /// # Algorithm overview: + /// 1. Calculate bounding box of the clip path + /// 2. Intersect with current clip bounding box + /// 3. For each tile in the intersected bounding box: + /// - If covered by zero winding: `push_zero_clip` + /// - If fully covered by non-zero winding: do nothing (clip is a no-op) + /// - If partially covered: `push_clip` + fn push_clip(&mut self, strips: impl Into>, layer_id: LayerId, thread_idx: u8) { + let strips = strips.into(); + let n_strips = strips.len(); + + // Calculate the bounding box of the clip path in strip coordinates + let path_bbox = if n_strips <= 1 { + WideTilesBbox::ZERO + } else { + // Calculate the y range from first to last strip in wide tile coordinates + let wtile_y0 = strips[0].strip_y(); + let wtile_y1 = strips[n_strips.saturating_sub(1)].strip_y() + 1; + + // Calculate the x range by examining all strips in wide tile coordinates + let mut wtile_x0 = strips[0].x / WideTile::WIDTH; + let mut wtile_x1 = wtile_x0; + for i in 0..n_strips.saturating_sub(1) { + let strip = &strips[i]; + let next_strip = &strips[i + 1]; + let width = + ((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16; + let x = strip.x; + wtile_x0 = wtile_x0.min(x / WideTile::WIDTH); + wtile_x1 = wtile_x1.max((x + width).div_ceil(WideTile::WIDTH)); + } + WideTilesBbox::new(wtile_x0, wtile_y0, wtile_x1, wtile_y1) + }; + + let parent_bbox = self.active_bbox(); + // Determine which tiles need clip processing: + // - For clipped filter layers: active_bbox() returns the full viewport, so parent_bbox + // already covers all tiles. We need to process all of them because the filter needs + // the entire layer rendered, and tiles outside the clip path must get `PushZeroClip` + // commands to properly suppress their content after filtering. + // - For normal clips: Intersect with the path bounds to only process tiles that are + // actually affected by the clip path, avoiding unnecessary work. + let clip_bbox = if self.clipped_filter_layer_depth > 0 { + // Use parent_bbox as-is (full viewport) to process all tiles + parent_bbox + } else { + // Optimize by processing only the intersection of parent and path bounds + parent_bbox.intersect(path_bbox) + }; + + let mut cur_wtile_x = clip_bbox.x0(); + let mut cur_wtile_y = clip_bbox.y0(); + let batch_count = self.batch_count; + + // Process strips to determine the clipping state for each wide tile + for i in 0..n_strips.saturating_sub(1) { + let strip = &strips[i]; + let strip_y = strip.strip_y(); + + // Skip strips before current wide tile row + if strip_y < cur_wtile_y { + continue; + } + + // Process wide tiles in rows before this strip's row + // These wide tiles are all zero-winding (outside the path) + while cur_wtile_y < strip_y.min(clip_bbox.y1()) { + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id); + } + // Reset x to the left edge of the clip bounding box + cur_wtile_x = clip_bbox.x0(); + // Move to the next row + cur_wtile_y += 1; + } + + // If we've reached the bottom of the clip bounding box, stop processing. + // Note that we are explicitly checking >= instead of ==, so that we abort if the clipping box + // is zero-area (see issue 1072). + if cur_wtile_y >= clip_bbox.y1() { + break; + } + + // Process wide tiles to the left of this strip in the same row + let x = strip.x; + let wtile_x_clamped = (x / WideTile::WIDTH).min(clip_bbox.x1()); + if cur_wtile_x < wtile_x_clamped { + // If winding is zero or doesn't match fill rule, these wide tiles are outside the path + let is_inside = strip.fill_gap(); + if !is_inside { + for wtile_x in cur_wtile_x..wtile_x_clamped { + self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id); + } + } + // If winding is nonzero, then wide tiles covered entirely + // by sparse fill are no-op (no clipping is applied). + cur_wtile_x = wtile_x_clamped; + } + + // Process wide tiles covered by the strip - these need actual clipping + let next_strip = &strips[i + 1]; + let width = + ((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16; + let wtile_x1 = (x + width).div_ceil(WideTile::WIDTH).min(clip_bbox.x1()); + if cur_wtile_x < wtile_x1 { + for wtile_x in cur_wtile_x..wtile_x1 { + let idx = self.get_idx(wtile_x, cur_wtile_y); + self.tiles[idx].push_clip( + idx, + &mut self.layers_needing_buf_stack, + layer_id, + batch_count, + ); + } + cur_wtile_x = wtile_x1; + } + } + + // Process any remaining wide tiles in the bounding box (all zero-winding) + while cur_wtile_y < clip_bbox.y1() { + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id); + } + cur_wtile_x = clip_bbox.x0(); + cur_wtile_y += 1; + } + + self.clip_stack.push(Clip { + clip_bbox, + strips, + thread_idx, + }); + } + + /// Get the bounding box of the current clip region or the entire viewport if no clip regions are active. + fn active_bbox(&self) -> WideTilesBbox { + // When in a clipped filter layer, use full viewport to allow + // filter to process the complete layer before applying clip as mask + if self.clipped_filter_layer_depth > 0 { + return self.full_viewport_bbox(); + } + + self.clip_stack + .last() + .map(|top| top.clip_bbox) + .unwrap_or_else(|| self.full_viewport_bbox()) + } + + /// Returns the bounding box covering the entire viewport in wide tile coordinates. + fn full_viewport_bbox(&self) -> WideTilesBbox { + WideTilesBbox::new(0, 0, self.width_tiles(), self.height_tiles()) + } + + /// Removes the most recently added clip region. + /// + /// This is the inverse operation of `push_clip`, carefully undoing all the clipping + /// operations while also handling any rendering needed for the clip region itself. + /// + /// # Algorithm overview: + /// 1. Retrieve the top clip from the stack + /// 2. For each wide tile in the clip's bounding box: + /// - If covered by zero winding: `pop_zero_clip` + /// - If fully covered by non-zero winding: do nothing (was no-op) + /// - If partially covered: render the clip and `pop_clip` + /// + /// This operation must be symmetric with `push_clip` to maintain a balanced clip stack. + fn pop_clip(&mut self) { + let Clip { + clip_bbox, + strips, + thread_idx, + } = self.clip_stack.pop().unwrap(); + let n_strips = strips.len(); + + // Compute base alpha index and create shared clip attributes + // Note: It's possible that the clip-path has zero strips. However, we cannot exit early + // in this case because we need to potentially pop zero clips further below. Therefore, + // we simply use a dummy alpha index of 0 in this case. + let clip_attrs_idx = self.attrs.clip.len() as u32; + let alpha_base_idx; + if n_strips == 0 { + alpha_base_idx = 0; + } else { + alpha_base_idx = strips[0].alpha_idx(); + self.attrs.clip.push(ClipAttrs { + thread_idx, + alpha_base_idx, + }); + }; + + let mut cur_wtile_x = clip_bbox.x0(); + let mut cur_wtile_y = clip_bbox.y0(); + let mut pop_pending = false; + + // Process each strip to determine the clipping state for each tile + for i in 0..n_strips.saturating_sub(1) { + let strip = &strips[i]; + let strip_y = strip.strip_y(); + + // Skip strips before current tile row + if strip_y < cur_wtile_y { + continue; + } + + // Process tiles in rows before this strip's row + // These tiles all had zero-winding clips + while cur_wtile_y < strip_y.min(clip_bbox.y1()) { + // Handle any pending clip pop from previous iteration + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + cur_wtile_x += 1; + } + + // Pop zero clips for all remaining tiles in this row + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip(); + } + cur_wtile_x = clip_bbox.x0(); + cur_wtile_y += 1; + } + + // If we've reached the bottom of the clip bounding box, stop processing + // Note that we are explicitly checking >= instead of ==, so that we abort if the clipping box + // is zero-area (see issue 1072). + if cur_wtile_y >= clip_bbox.y1() { + break; + } + + // Process tiles to the left of this strip in the same row + let x0 = strip.x; + let wtile_x_clamped = (x0 / WideTile::WIDTH).min(clip_bbox.x1()); + if cur_wtile_x < wtile_x_clamped { + // Handle any pending clip pop from previous iteration + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + cur_wtile_x += 1; + } + + // Pop zero clips for tiles that had zero winding or didn't match fill rule + // TODO: The winding check is probably not needed; if there was a fill, + // the logic below should have advanced wtile_x. + let is_inside = strip.fill_gap(); + if !is_inside { + for wtile_x in cur_wtile_x..wtile_x_clamped { + self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip(); + } + } + cur_wtile_x = wtile_x_clamped; + } + + // Process tiles covered by the strip - render clip content and pop + let next_strip = &strips[i + 1]; + let strip_width = + ((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16; + let mut clipped_x1 = x0 + strip_width; + let wtile_x0 = (x0 / WideTile::WIDTH).max(clip_bbox.x0()); + let wtile_x1 = clipped_x1.div_ceil(WideTile::WIDTH).min(clip_bbox.x1()); + + // Calculate starting position and column for alpha mask + let mut x = x0; + let mut col = strip.alpha_idx() / u32::from(Tile::HEIGHT); + let clip_x = clip_bbox.x0() * WideTile::WIDTH; + if clip_x > x { + col += u32::from(clip_x - x); + x = clip_x; + clipped_x1 = clip_x.max(clipped_x1); + } + + // Render clip strips for each affected tile and mark for popping + for wtile_x in wtile_x0..wtile_x1 { + // If we've moved past tile_x and have a pending pop, do it now + if cur_wtile_x < wtile_x && core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + } + + // Calculate the portion of the strip that affects this tile + let x_rel = x % WideTile::WIDTH; + let width = clipped_x1.min((wtile_x + 1) * WideTile::WIDTH) - x; + + // Create clip strip command for rendering the partial coverage + let cmd = CmdClipAlphaFill { + x: x_rel, + width, + alpha_offset: col * u32::from(Tile::HEIGHT) - alpha_base_idx, + attrs_idx: clip_attrs_idx, + }; + x += width; + col += u32::from(width); + + // Apply the clip strip command and update state + self.get_mut(wtile_x, cur_wtile_y).clip_strip(cmd); + cur_wtile_x = wtile_x; + + // Only request a pop if the x coordinate is actually inside the bounds. + if cur_wtile_x < clip_bbox.x1() { + pop_pending = true; + } + } + + // Handle fill regions between strips based on fill rule + let is_inside = next_strip.fill_gap(); + if is_inside && strip_y == next_strip.strip_y() { + if cur_wtile_x >= clip_bbox.x1() { + continue; + } + + let x2 = next_strip.x; + let clipped_x2 = x2.min((cur_wtile_x + 1) * WideTile::WIDTH); + let width = clipped_x2.saturating_sub(clipped_x1); + + // If there's a gap, fill it. Only do this if the fill wouldn't cover the + // whole tile, as such clips are skipped by the `push_clip` function. See + // + if width > 0 && width < WideTile::WIDTH { + let x_rel = clipped_x1 % WideTile::WIDTH; + self.get_mut(cur_wtile_x, cur_wtile_y) + .clip_fill(x_rel, width); + } + + // If the next strip is a sentinel, skip the fill + // It's a sentinel in the row if there is non-zero winding for the sparse fill + // Look more into this in the strip.rs render function + if x2 == u16::MAX { + continue; + } + + // If fill extends to next tile, pop current and handle next + if x2 > (cur_wtile_x + 1) * WideTile::WIDTH { + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + } + + let width2 = x2 % WideTile::WIDTH; + cur_wtile_x = x2 / WideTile::WIDTH; + + // If the strip is outside the clipping box, we don't need to do any + // filling, so we continue (also to prevent out-of-bounds access). + if cur_wtile_x >= clip_bbox.x1() { + continue; + } + + if width2 > 0 { + // An important thing to note: Note that we are only applying + // `clip_fill` to the wide tile that is actually covered by the next + // strip, and not the ones in-between! For example, if the first strip + // is in wide tile 1 and the second in wide tile 4, we will do a clip + // fill in wide tile 1 and 4, but not in 2 and 3. The reason for this is + // that any tile in-between is fully covered and thus no clipping is + // necessary at all. See also the `push_clip` function, where we don't + // push a new buffer for such tiles. + self.get_mut(cur_wtile_x, cur_wtile_y).clip_fill(0, width2); + } + } + } + } + + // Handle any pending clip pop from the last iteration + if core::mem::take(&mut pop_pending) { + self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip(); + cur_wtile_x += 1; + } + + // Process any remaining tiles in the bounding box (all zero-winding) + while cur_wtile_y < clip_bbox.y1() { + for wtile_x in cur_wtile_x..clip_bbox.x1() { + self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip(); + } + cur_wtile_x = clip_bbox.x0(); + cur_wtile_y += 1; + } + } +} + +/// A wide tile. +#[derive(Debug)] +pub struct WideTile { + /// The x coordinate of the wide tile. + pub x: u16, + /// The y coordinate of the wide tile. + pub y: u16, + /// The background of the tile. + pub bg: PremulColor, + /// The draw commands of the tile. + pub cmds: Vec, + /// The number of zero-winding clips. + n_zero_clip: usize, + /// The number of non-zero-winding clips. + n_clip: usize, + /// The number of pushed buffers, including buffers for clips. + /// + /// Note not all layers require their own buffers; see [`Layer::needs_buf`]. + n_bufs: usize, + /// True when this tile is in a filtered layer with clipping applied. + /// When set, clip operations generate explicit commands instead of just + /// tracking counters, allowing filters to process clipped content correctly. + in_clipped_filter_layer: bool, + /// Maps layer Id to command ranges for this tile. + pub layer_cmd_ranges: HashMap, + /// Vector of layer IDs this tile participates in. + layer_ids: Vec, + /// Tracks the index into `cmds` of each `Start`/`PushBuf` command on the current stack. + /// + /// Only used in `HYBRID` mode. + push_buf_indices: Vec, + /// Whether at least one filter layer has been pushed on this tile. + had_filter_layer: bool, + /// Indicates whether the main target surface is used as a blend target for a non + /// src-over blending operation. + /// + /// This will only be set in `HYBRID` mode. + surface_is_blend_target: bool, + /// Watermark: the batch count at which this tile last emitted `BatchEnd` markers. + /// Only meaningful in `MODE_HYBRID`. + last_batch_end: u32, +} + +impl WideTile { + /// The width of a wide tile in pixels. + pub const WIDTH: u16 = 256; + /// The maximum coordinate of a wide tile. + pub const MAX_WIDE_TILE_COORD: u16 = u16::MAX / Self::WIDTH; +} + +impl WideTile { + /// Create a new wide tile. + pub fn new(width: u16, height: u16) -> Self { + Self::new_internal(width, height) + } +} + +impl WideTile { + /// Create a new wide tile. + pub fn new(width: u16, height: u16) -> Self { + Self::new_internal(width, height) + } +} + +impl WideTile { + /// Create a new wide tile. + fn new_internal(x: u16, y: u16) -> Self { + let mut layer_cmd_ranges = HashMap::new(); + layer_cmd_ranges.insert(0, LayerCommandRanges::default()); + Self { + x, + y, + bg: PremulColor::from_alpha_color(TRANSPARENT), + cmds: vec![], + n_zero_clip: 0, + n_clip: 0, + n_bufs: 0, + in_clipped_filter_layer: false, + had_filter_layer: false, + layer_cmd_ranges, + layer_ids: vec![LayerKind::Regular(0)], + push_buf_indices: vec![TARGET_SURFACE_PUSH_BUF_IDX], + surface_is_blend_target: false, + last_batch_end: 0, + } + } + + #[inline] + fn reset(&mut self) { + self.bg = PremulColor::from_alpha_color(TRANSPARENT); + self.cmds.clear(); + self.n_zero_clip = 0; + self.n_clip = 0; + self.n_bufs = 0; + self.in_clipped_filter_layer = false; + self.had_filter_layer = false; + self.layer_ids.truncate(1); + self.layer_cmd_ranges.clear(); + self.layer_cmd_ranges + .insert(0, LayerCommandRanges::default()); + self.push_buf_indices.clear(); + // We can't use 0 here, because then we have no way of distinguishing it from a + // user-supplied `PushBuf` at position 0. + self.push_buf_indices.push(TARGET_SURFACE_PUSH_BUF_IDX); + self.surface_is_blend_target = false; + self.last_batch_end = 0; + } + + /// Push all layer buffers that have not yet been pushed for this tile. + /// Also emit `BatchEnd` commands in case we are in HYBRID mode. + /// + /// We push wide tile layer buffers lazily. This is called by wide tile draw methods to ensure + /// all layer stack buffers are pushed for the wide tile. Calling this multiple times won't + /// push additional buffers beyond the first call (until the layer stack changes). + /// + /// The `tile_idx` parameter is this tile's index in [`Wide::tiles`]. + #[inline(always)] + fn ensure_layer_stack_bufs( + &mut self, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + batch_count: u32, + ) { + // First emit `BatchEnd` commands if necessary. + if MODE == MODE_HYBRID && self.last_batch_end < batch_count { + let count = (batch_count - self.last_batch_end) as usize; + self.cmds.extend(core::iter::repeat_n(Cmd::BatchEnd, count)); + self.last_batch_end = batch_count; + } + + // Then, take care of emitting `PushBuf` commands that haven't been emitted yet. + + // `layers` tracks the number of layers that require scratch buffers, excluding those + // required for clips: clip buffers are handled separately. The scratch buffer stack for + // this tile is `self.n_bufs`, of which `self.n_clip` are for clips. + let layer_bufs = self.n_bufs - self.n_clip; + debug_assert!( + layer_bufs <= layers.len, + "tile `layer_buf_depth` exceeds active layer stack" + ); + // It may be quite likely that no buffers need to be pushed: e.g. a tile that has content + // may well have more than one call to `ensure_layer_stack_bufs`, and layers needing + // buffers are not necessarily the most common case. As such, we keep this check inlined + // (because the function itself is inlined), and if buffers are needed do an explicit + // function call that is not inlined. That keeps the generated code size small at our call + // sites. + (layer_bufs < layers.len).then( + #[inline(never)] + || { + for layer in &mut layers.stack[layer_bufs..layers.len] { + layer.occupied_tiles.push(tile_idx); + self.push_buf(layer.kind); + } + }, + ); + } + + /// Fill a rectangular region with a paint. + /// + /// Generates fill commands unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, commands are always generated since filters need the full + /// layer content rendered before applying the clip as a mask. + /// + /// The `fill_hint` parameter is pre-computed by the caller based on paint type: + /// - `OpaqueSolid(color)`: Paint is an opaque solid color, can replace background + /// - `OpaqueImage`: Paint is an opaque image, can clear previous commands + /// - `None`: No optimization available + fn fill( + &mut self, + batch_count: u32, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + x: u16, + width: u16, + attrs_idx: u32, + current_layer_id: LayerId, + fill_hint: FillHint, + ) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.ensure_layer_stack_bufs(tile_idx, layers, batch_count); + + // Check if we can apply overdraw elimination optimization. + // This requires filling the entire tile width with no clip/buffer stack. + // + // Note that we could be more aggressive in optimizing a whole-tile opaque fill + // even with a clip stack. It would be valid to elide all drawing commands from + // the enclosing clip push up to the fill. Further, we could extend the clip + // push command to include a background color, rather than always starting with + // a transparent buffer. Lastly, a sequence of push(bg); strip/fill; pop could + // be replaced with strip/fill with the color (the latter is true even with a + // non-opaque color). + // + // However, the extra cost of tracking such optimizations may outweigh the + // benefit, especially in hybrid mode with GPU painting. + + let can_override = x == 0 + && width == WideTile::WIDTH + && self.n_clip == 0 + && self.n_bufs == 0 + // TODO: Relax this condition to only trigger for filter graphs that contain at + // least 1 primitive filter that has non-local effects (see + // https://github.com/linebender/vello/pull/1526#discussion_r3007377869) + && !self.had_filter_layer; + + if can_override { + match fill_hint { + FillHint::OpaqueSolid(color) => { + self.cmds.clear(); + self.bg = color; + + // We need to invalidate the ranges of all layers that have been drawn so far + // in that wide tile. + self.layer_cmd_ranges.clear(); + self.layer_cmd_ranges + .insert(current_layer_id, LayerCommandRanges::default()); + return; + } + FillHint::OpaqueImage => { + // Opaque image: clear previous commands but still emit the fill. + self.cmds.clear(); + self.bg = PremulColor::from_alpha_color(TRANSPARENT); + + // We need to invalidate the ranges of all layers that have been drawn so far + // in that wide tile. + self.layer_cmd_ranges.clear(); + self.layer_cmd_ranges + .insert(current_layer_id, LayerCommandRanges::default()); + // Fall through to emit the fill command below, as opposed to + // solid paints where we have a return statement. + } + FillHint::None => {} + } + } + + self.record_fill_cmd(current_layer_id, self.cmds.len()); + self.cmds.push(Cmd::Fill(CmdFill { + x, + width, + attrs_idx, + })); + } + } + + /// Fill a region using an alpha mask from a strip. + /// + /// Generates alpha fill commands unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, commands are always generated since filters need the full + /// layer content rendered before applying the clip as a mask. + fn strip( + &mut self, + batch_count: u32, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + cmd_strip: CmdAlphaFill, + current_layer_id: LayerId, + ) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.ensure_layer_stack_bufs(tile_idx, layers, batch_count); + + self.record_fill_cmd(current_layer_id, self.cmds.len()); + self.cmds.push(Cmd::AlphaFill(cmd_strip)); + } + } + + /// Adds a new clip region to the current wide tile. + /// + /// Pushes a clip buffer unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, clip buffers are always pushed since filters need explicit + /// clip state to process the full layer before applying the clip as a mask. + fn push_clip( + &mut self, + tile_idx: usize, + layers: &mut NeedsBufLayerStack, + layer_id: LayerId, + batch_count: u32, + ) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.ensure_layer_stack_bufs(tile_idx, layers, batch_count); + self.push_buf(LayerKind::Clip(layer_id)); + self.n_clip += 1; + } + } + + /// Removes the most recently added clip region from the current wide tile. + /// + /// Pops a clip buffer unless the tile is in a zero-clip region (fully clipped out). + /// For clipped filter layers, clip buffers are always popped since filters need explicit + /// clip state to process the full layer before applying the clip as a mask. + fn pop_clip(&mut self) { + if !self.is_zero_clip() || self.in_clipped_filter_layer { + self.pop_buf(); + self.n_clip -= 1; + } + } + + /// Adds a zero-winding clip region to the stack. + /// + /// Zero-winding clips represent tiles completely outside the clip path. + /// Normally these just increment a counter to suppress drawing, but for + /// clipped filter layers we generate explicit commands so filters can + /// process the entire layer before applying the clip as a mask. + fn push_zero_clip(&mut self, layer_id: LayerId) { + if self.in_clipped_filter_layer { + // Generate explicit command for filter processing + self.cmds.push(Cmd::PushZeroClip(layer_id)); + } + self.n_zero_clip += 1; + } + + /// Removes the most recently added zero-winding clip region. + fn pop_zero_clip(&mut self) { + if self.in_clipped_filter_layer { + // Generate explicit command for filter processing + self.cmds.push(Cmd::PopZeroClip); + } + self.n_zero_clip -= 1; + } + + /// Checks if the current clip region is a zero-winding clip. + fn is_zero_clip(&mut self) -> bool { + self.n_zero_clip > 0 + } + + /// Applies a clip strip operation with the given parameters. + /// + /// Note: Unlike content operations (`strip`, `push_clip`, etc.), clip operations don't need + /// the `|| self.in_clipped_filter_layer` check. Filter effects need full layer *content* + /// rendered (even in zero-clip areas). + fn clip_strip(&mut self, cmd_clip_strip: CmdClipAlphaFill) { + if (!self.is_zero_clip()) && !matches!(self.cmds.last(), Some(Cmd::PushBuf(..))) { + self.cmds.push(Cmd::ClipStrip(cmd_clip_strip)); + } + } + + /// Applies a clip fill operation at the specified position and width. + fn clip_fill(&mut self, x: u16, width: u16) { + if (!self.is_zero_clip()) && !matches!(self.cmds.last(), Some(Cmd::PushBuf(..))) { + self.cmds.push(Cmd::ClipFill(CmdClipFill { x, width })); + } + } + + /// Records the fill command for a specific layer. + fn record_fill_cmd(&mut self, layer_id: LayerId, cmd_idx: usize) { + self.layer_cmd_ranges.entry(layer_id).and_modify(|ranges| { + ranges.full_range.end = cmd_idx + 1; + if ranges.render_range.is_empty() { + ranges.render_range = cmd_idx..cmd_idx + 1; + } else { + ranges.render_range.end = cmd_idx + 1; + } + }); + } + + /// Whether the set of pushed commands so far indicates that the surface is used as + /// a blend target. + pub fn surface_is_blend_target(&self) -> bool { + self.surface_is_blend_target + } + + /// Push a buffer for a new layer. + /// + /// Different layer kinds are handled differently: + /// - Regular layers: Use local `blend_buf` stack for temporary storage + /// - Filtered layers: Materialized in persistent layer storage for filter processing + /// - Clip layers: Special handling for clipping operations + #[inline(always)] + fn push_buf(&mut self, layer_kind: LayerKind) { + let top_layer = layer_kind.id(); + if matches!(layer_kind, LayerKind::Filtered(_)) { + self.layer_cmd_ranges.insert( + top_layer, + LayerCommandRanges { + full_range: self.cmds.len()..self.cmds.len() + 1, + // Start with empty render_range; will be updated by `record_fill_cmd` and `pop_buf`. + render_range: self.cmds.len() + 1..self.cmds.len() + 1, + }, + ); + } else if matches!(layer_kind, LayerKind::Clip(_)) { + self.layer_cmd_ranges.entry(top_layer).and_modify(|ranges| { + ranges.full_range.end = self.cmds.len() + 1; + // Start with empty render_range; will be updated by `record_fill_cmd` and `pop_buf`. + ranges.render_range = self.cmds.len() + 1..self.cmds.len() + 1; + }); + } + + if MODE == MODE_HYBRID { + self.push_buf_indices.push(self.cmds.len()); + } + + self.cmds.push(Cmd::PushBuf(layer_kind, false)); + self.layer_ids.push(layer_kind); + self.n_bufs += 1; + } + + /// Pop the most recent buffer. + #[inline(always)] + fn pop_buf(&mut self) { + if MODE == MODE_HYBRID { + self.push_buf_indices.pop(); + } + + let top_layer = self.layer_ids.pop().unwrap(); + let mut next_layer = *self.layer_ids.last().unwrap(); + + if matches!(self.cmds.last(), Some(&Cmd::PushBuf(..))) { + // Optimization: If no drawing happened between the last `PushBuf`, + // we can just pop it instead. + self.cmds.pop(); + } else { + self.layer_cmd_ranges + .entry(top_layer.id()) + .and_modify(|ranges| { + ranges.full_range.end = self.cmds.len() + 1; + }); + if top_layer.id() == next_layer.id() { + next_layer = *self + .layer_ids + .get(self.layer_ids.len().saturating_sub(2)) + .unwrap(); + } + + self.layer_cmd_ranges + .entry(next_layer.id()) + .and_modify(|ranges| { + ranges.full_range.end = self.cmds.len() + 1; + ranges.render_range.end = self.cmds.len() + 1; + }); + self.cmds.push(Cmd::PopBuf); + } + self.n_bufs -= 1; + } + + /// Apply an opacity to the whole buffer. + fn opacity(&mut self, opacity: f32) { + if opacity != 1.0 { + self.cmds.push(Cmd::Opacity(opacity)); + } + } + + /// Apply a filter effect to the whole buffer. + pub fn filter(&mut self, layer_id: LayerId, filter: Filter) { + self.had_filter_layer = true; + self.cmds.push(Cmd::Filter(layer_id, filter)); + } + + /// Apply a mask to the whole buffer. + fn mask(&mut self, mask: Mask) { + self.cmds.push(Cmd::Mask(mask)); + } + + /// Blend the current buffer into the previous buffer in the stack. + fn blend(&mut self, blend_mode: BlendMode) { + #[allow(clippy::collapsible_if, reason = "better expresses intent")] + if MODE == MODE_HYBRID { + // Whether we use a non-default blend mode to blend into the destination. + let blends_into_dest = + blend_mode.mix != Mix::Normal || blend_mode.compose != Compose::SrcOver; + + if blends_into_dest && self.push_buf_indices.len() >= 2 { + let nos_idx = self.push_buf_indices[self.push_buf_indices.len() - 2]; + + if nos_idx == TARGET_SURFACE_PUSH_BUF_IDX { + self.surface_is_blend_target = true; + } else { + match &mut self.cmds[nos_idx] { + Cmd::PushBuf(_, is_blend_target) => *is_blend_target = true, + // Anything else shouldn't be possible. + _ => unreachable!(), + } + } + } + } + + self.cmds.push(Cmd::Blend(blend_mode)); + } +} + +/// Debug utilities for wide tiles. +/// +/// These methods are only available in debug builds (`debug_assertions`). +/// They provide introspection into the command buffer for debugging and logging purposes. +#[cfg(debug_assertions)] +impl WideTile { + /// Lists all commands in this wide tile with their indices and names. + /// + /// Returns a formatted string with each command on a new line, showing its index + /// and human-readable name. This is useful for debugging and understanding the + /// command sequence. + /// + /// # Example + /// + /// ```ignore + /// let commands = wide_tile.list_commands(); + /// println!("{}", commands); + /// // Output: + /// // 0: PushBuf(Regular) + /// // 1: FillPath + /// // 2: PushZeroClip + /// // 3: FillPath + /// // 4: PopBuf + /// ``` + #[allow(dead_code, reason = "useful for debugging")] + pub fn list_commands(&self) -> String { + self.cmds + .iter() + .enumerate() + .map(|(i, cmd)| format!("{}: {}", i, cmd.name())) + .collect::>() + .join("\n") + } +} + +/// Optimization hint for fill operations, computed in `Wide::generate` and passed to `WideTile::fill`. +/// +/// This enum communicates whether a fill operation can benefit from overdraw elimination: +/// - For opaque solid colors: we can set the background color directly and skip the fill +/// - For opaque images: we can clear previous commands but still need to emit the fill +#[derive(Debug, Clone, Copy)] +pub enum FillHint { + /// No optimization possible, emit fill command normally. + None, + /// Paint is an opaque solid color - can replace background if conditions are met. + OpaqueSolid(PremulColor), + /// Paint is an opaque image - can clear previous commands if conditions are met. + OpaqueImage, +} + +/// Distinguishes between different types of layers and their storage strategies. +/// +/// Each layer kind determines how the layer's content is stored and processed: +/// - Regular layers are blended on-the-fly using a temporary buffer stack +/// - Filtered layers are materialized in persistent storage for filter processing +/// - Clip layers are special buffers used for clipping operations +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayerKind { + /// Regular layer using local `blend_buf` stack for temporary storage. + Regular(LayerId), + /// Filtered layer materialized in persistent `layer_manager` storage. + Filtered(LayerId), + /// Clip layer for clipping operations. + Clip(LayerId), +} + +impl LayerKind { + /// Get the underlying layer ID. + /// + /// All layer kinds contain a layer ID that uniquely identifies the layer. + pub fn id(&self) -> LayerId { + match self { + Self::Regular(id) | Self::Filtered(id) | Self::Clip(id) => *id, + } + } +} + +/// A drawing command for wide tiles. +/// +/// Commands are executed in order to render the final image. They include +/// drawing operations (`Fill`, `AlphaFill`), layer management (`PushBuf`, `PopBuf`), +/// clipping operations (`ClipFill`, `ClipStrip`), and post-processing effects +/// (`Filter`, `Blend`, `Opacity`, `Mask`). +#[derive(Clone, Debug, PartialEq)] +pub enum Cmd { + /// Fill a rectangular region with a solid color or paint. + Fill(CmdFill), + /// Fill a region with a paint, modulated by an alpha mask. + AlphaFill(CmdAlphaFill), + /// Pushes a new buffer for drawing. + /// Regular layers use the local `blend_buf` stack. + /// Filtered layers are materialized in persistent layer storage. + /// + /// The second argument indicates whether the layer that is about to be pushed + /// will be used as a destination for a blending operation with a non-default blend mode. + /// This information is only needed by `vello_hybrid` for scheduling purposes. + PushBuf(LayerKind, bool), + /// Pops the most recent buffer and blends it into the previous buffer. + PopBuf, + /// A fill command within a clipping region. + /// + /// This command will blend the contents of the current buffer within the clip fill region + /// into the previous buffer in the stack. + ClipFill(CmdClipFill), + /// A fill command with alpha mask within a clipping region. + /// + /// This command will blend the contents of the current buffer within the clip fill region + /// into the previous buffer in the stack, with an additional alpha mask. + ClipStrip(CmdClipAlphaFill), + /// Marks entry into a zero-winding clip region for a clipped filter layer. + /// + /// Zero-winding clips represent tiles completely outside the clip path. For clipped + /// filter layers, this command allows the filter to process the full layer content + /// before applying the clip as a mask (per SVG spec: filter → clip → mask → blend). + PushZeroClip(LayerId), + /// Marks exit from a zero-winding clip region for a clipped filter layer. + PopZeroClip, + /// Apply a filter effect to a layer's contents. + /// + /// This command applies a filter (e.g., blur, drop shadow) to the specified layer's + /// rendered content. Per the SVG specification, filters are applied before clipping, + /// masking, blending, and opacity operations. + Filter(LayerId, Filter), + /// Blend the current buffer into the previous buffer. + /// + /// This command blends the contents of the current buffer into the previous buffer + /// using the specified blend mode (e.g., multiply, screen, overlay). + Blend(BlendMode), + /// Apply uniform opacity to the current buffer. + /// + /// Multiplies the alpha channel of all pixels in the buffer by the given opacity value. + Opacity(f32), + /// Apply a mask to the current buffer. + /// + /// Modulates the alpha channel of the buffer using the provided mask. + Mask(Mask), + /// Marks a boundary between rendering fast path strips and coarse rasterized strips. + /// + /// Only meaningful in `MODE_HYBRID`. + // TODO: Consider removing this, see https://github.com/linebender/vello/pull/1479#discussion_r2869917343. + BatchEnd, +} + +#[cfg(debug_assertions)] +impl Cmd { + /// Returns a human-readable name for this command. + /// + /// This is useful for debugging, logging, and displaying command information + /// in a user-friendly format. To get detailed paint information, use `name_with_attrs` + /// which can look up the paint from the command attributes. + /// + /// **Note:** This method is only available in debug builds (`debug_assertions`). + pub fn name(&self) -> &'static str { + match self { + Self::Fill(_) => "FillPath", + Self::AlphaFill(_) => "AlphaFillPath", + Self::PushBuf(layer_kind, needs_temp) => match (layer_kind, needs_temp) { + (LayerKind::Regular(_), false) => "PushBuf(Regular, false)", + (LayerKind::Regular(_), true) => "PushBuf(Regular, true)", + (LayerKind::Filtered(_), false) => "PushBuf(Filtered, false)", + (LayerKind::Filtered(_), true) => "PushBuf(Filtered, true)", + (LayerKind::Clip(_), false) => "PushBuf(Clip, false)", + (LayerKind::Clip(_), true) => "PushBuf(Clip, true)", + }, + Self::PopBuf => "PopBuf", + Self::ClipFill(_) => "ClipPathFill", + Self::ClipStrip(_) => "ClipPathStrip", + Self::PushZeroClip(_) => "PushZeroClip", + Self::PopZeroClip => "PopZeroClip", + Self::Filter(_, _) => "Filter", + Self::Blend(_) => "Blend", + Self::Opacity(_) => "Opacity", + Self::Mask(_) => "Mask", + Self::BatchEnd => "BatchEnd", + } + } + + /// Returns a human-readable name for this command with detailed paint information. + /// + /// This variant looks up paint details from the command attributes for fill commands. + /// + /// **Note:** This method is only available in debug builds (`debug_assertions`). + pub fn name_with_attrs( + &self, + fill_attrs: &[FillAttrs], + encoded_paints: &[EncodedPaint], + ) -> String { + match self { + Self::Fill(cmd) => { + if let Some(attrs) = fill_attrs.get(cmd.attrs_idx as usize) { + format!("FillPath({})", paint_name(&attrs.paint, encoded_paints)) + } else { + format!("FillPath(attrs_idx={})", cmd.attrs_idx) + } + } + Self::AlphaFill(cmd) => { + if let Some(attrs) = fill_attrs.get(cmd.attrs_idx as usize) { + format!( + "AlphaFillPath({})", + paint_name(&attrs.paint, encoded_paints) + ) + } else { + format!("AlphaFillPath(attrs_idx={})", cmd.attrs_idx) + } + } + _ => self.name().into(), + } + } +} + +/// Returns a human-readable description of a paint. +#[cfg(debug_assertions)] +fn paint_name(paint: &Paint, encoded_paints: &[EncodedPaint]) -> String { + match paint { + Paint::Solid(color) => { + let rgba = color.as_premul_rgba8(); + format!( + "Solid(#{:02x}{:02x}{:02x}{:02x})", + rgba.r, rgba.g, rgba.b, rgba.a + ) + } + Paint::Indexed(idx) => { + let index = idx.index(); + if let Some(encoded) = encoded_paints.get(index) { + let kind = match encoded { + EncodedPaint::Gradient(g) => match &g.kind { + crate::encode::EncodedKind::Linear(_) => "LinearGradient", + crate::encode::EncodedKind::Radial(_) => "RadialGradient", + crate::encode::EncodedKind::Sweep(_) => "SweepGradient", + }, + EncodedPaint::Image(_) => "Image", + EncodedPaint::ExternalTexture(_) => "ExternalTexture", + EncodedPaint::BlurredRoundedRect(_) => "BlurredRoundedRect", + }; + format!("{}[{}]", kind, index) + } else { + format!("Indexed({})", index) + } + } + } +} + +/// Shared attributes for alpha fill commands. +#[derive(Debug, Clone, PartialEq)] +pub struct FillAttrs { + /// The index of the thread that owns the alpha buffer + /// containing the mask values at `alpha_idx`. + /// Always 0 in single-threaded mode. + pub thread_idx: u8, + /// The paint (color, gradient, etc.) to fill the region with. + // TODO: Store premultiplied colors as indexed paints as well, to reduce + // memory overhead? Or get rid of indexed paints and inline all paints? + pub paint: Paint, + /// The blend mode to apply before drawing the contents. + pub blend_mode: BlendMode, + /// A mask to apply to the command. + pub mask: Option, + /// Base index into the alpha buffer for this path's commands. + /// Commands store a relative offset that is added to this base. + alpha_base_idx: u32, +} + +impl FillAttrs { + /// Compute the absolute alpha buffer index from a relative offset. + pub fn alpha_idx(&self, offset: u32) -> u32 { + self.alpha_base_idx + offset + } +} + +/// Shared attributes for clip alpha fill commands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClipAttrs { + /// The index of the thread that owns the alpha buffer + /// containing the mask values at `alpha_idx`. + /// Always 0 in single-threaded mode. + pub thread_idx: u8, + /// Base index into the alpha buffer for this clip path's commands. + /// Commands store a relative offset that is added to this base. + alpha_base_idx: u32, +} + +impl ClipAttrs { + /// Compute the absolute alpha buffer index from a relative offset. + pub fn alpha_idx(&self, offset: u32) -> u32 { + self.alpha_base_idx + offset + } +} + +/// Container for shared command attributes. +/// +/// This struct holds the shared attributes for fill and clip commands, +/// allowing them to be passed together to functions that need both. +#[derive(Debug, Default, Clone)] +pub struct CommandAttrs { + /// Shared attributes for fill commands, indexed by `attrs_idx` in `CmdFill`/`CmdAlphaFill`. + pub fill: Vec, + /// Shared attributes for clip commands, indexed by `attrs_idx` in `CmdClipAlphaFill`. + pub clip: Vec, +} + +impl CommandAttrs { + /// Clear all attributes. + pub fn clear(&mut self) { + self.fill.clear(); + self.clip.clear(); + } +} + +/// Fill a consecutive horizontal region of a wide tile. +/// +/// This command fills a rectangular region with the specified paint. +/// The region starts at x-coordinate `x` and extends for `width` pixels +/// horizontally, spanning the full height of the wide tile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CmdFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the filled region in pixels. + pub width: u16, + /// Index into the command attributes array. + pub attrs_idx: u32, +} + +/// Fill a consecutive horizontal region with an alpha mask. +/// +/// Similar to `CmdFill`, but modulates the paint by an alpha mask stored +/// in a separate buffer. This is used for anti-aliased edges and partial +/// coverage from path rasterization. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CmdAlphaFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the filled region in pixels. + pub width: u16, + /// Relative offset to the alpha buffer location. + /// Use `FillAttrs::alpha_idx(alpha_offset)` to compute the absolute index. + pub alpha_offset: u32, + /// Index into the command attributes array. + pub attrs_idx: u32, +} + +/// Fill operation within a clipping region. +/// +/// This command copies a horizontal region from the top of the clip buffer stack +/// to the next buffer on the stack, effectively rendering the clipped content. +/// Unlike `CmdFill`, this doesn't fill with a paint but transfers existing content. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CmdClipFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the region to copy in pixels. + pub width: u16, +} + +/// Alpha-masked fill operation within a clipping region. +/// +/// This command composites a horizontal region from the top of the clip buffer stack +/// to the next buffer, modulated by an alpha mask. This is used for anti-aliased +/// clip edges. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CmdClipAlphaFill { + /// The horizontal start position relative to the wide tile's left edge, in pixels. + pub x: u16, + /// The width of the region to composite in pixels. + pub width: u16, + /// Relative offset to the alpha buffer location. + /// Use `ClipAttrs::alpha_idx(alpha_offset)` to compute the absolute index. + pub alpha_offset: u32, + /// Index into the clip attributes array. + pub attrs_idx: u32, +} + +/// Ranges of commands for a specific layer in a specific tile. +/// +/// This structure tracks two different ranges of commands: +/// - The full range includes all layer operations (push, draw, pop) +/// - The render range includes only the actual drawing commands +#[derive(Debug, Clone, Default)] +pub struct LayerCommandRanges { + /// Full range including `PushBuf`, all commands, and `PopBuf`. + pub full_range: Range, + /// Range containing only fill commands (`Fill`, `AlphaFill`). + /// This is the range to replace when sampling from a filtered layer. + pub render_range: Range, +} + +impl LayerCommandRanges { + /// Clear the full range and render range. + #[inline] + pub fn clear(&mut self) { + self.full_range = 0..0; + self.render_range = 0..0; + } +} + +#[cfg(test)] +mod tests { + use crate::coarse::{FillHint, LayerKind, MODE_CPU, NeedsBufLayerStack, Wide, WideTile}; + use crate::kurbo::Affine; + use crate::paint::{Paint, PremulColor}; + use crate::peniko; + use crate::peniko::{BlendMode, Compose, Mix}; + use crate::render_graph::RenderGraph; + use crate::strip::Strip; + use alloc::{boxed::Box, vec}; + + #[test] + fn optimize_empty_layers() { + let mut wide = WideTile::::new(0, 0); + wide.push_buf(LayerKind::Regular(0)); + wide.pop_buf(); + + assert!(wide.cmds.is_empty()); + } + + #[test] + fn basic_layer() { + let mut wide = WideTile::::new(0, 0); + let mut layers = NeedsBufLayerStack::default(); + layers.push(LayerKind::Regular(0)); + wide.push_buf(LayerKind::Regular(0)); + wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None); + wide.fill(0, 0, &mut layers, 10, 10, 0, 0, FillHint::None); + wide.pop_buf(); + + assert_eq!(wide.cmds.len(), 4); + } + + #[test] + fn dont_inline_blend_with_two_fills() { + let blend_mode = BlendMode::new(Mix::Lighten, Compose::SrcOver); + + let mut wide = WideTile::::new(0, 0); + let mut layers = NeedsBufLayerStack::default(); + layers.push(LayerKind::Regular(0)); + wide.push_buf(LayerKind::Regular(0)); + wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None); + wide.fill(0, 0, &mut layers, 10, 10, 0, 0, FillHint::None); + wide.blend(blend_mode); + wide.pop_buf(); + + assert_eq!(wide.cmds.len(), 5); + } + + #[test] + fn dont_inline_destructive_blend() { + let blend_mode = BlendMode::new(Mix::Lighten, Compose::Clear); + + let mut wide = WideTile::::new(0, 0); + let mut layers = NeedsBufLayerStack::default(); + layers.push(LayerKind::Regular(0)); + wide.push_buf(LayerKind::Regular(0)); + wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None); + wide.blend(blend_mode); + wide.pop_buf(); + + assert_eq!(wide.cmds.len(), 4); + } + + #[test] + fn tile_coordinates() { + let wide = Wide::::new(1000, 258); + + let tile_1 = wide.get(1, 3); + assert_eq!(tile_1.x, 256); + assert_eq!(tile_1.y, 12); + + let tile_2 = wide.get(2, 15); + assert_eq!(tile_2.x, 512); + assert_eq!(tile_2.y, 60); + } + + #[test] + fn reset_clears_layer_and_clip_stacks() { + type ClipPath = Option>; + + let mut wide = Wide::::new(1000, 258); + let mut render_graph = RenderGraph::new(); + let no_clip_path: ClipPath = None; + wide.push_layer( + 1, + no_clip_path, + BlendMode::default(), + None, + 0.5, + None, + Affine::IDENTITY, + &mut render_graph, + 0, + ); + + assert_eq!(wide.layer_stack.len(), 1); + assert_eq!(wide.clip_stack.len(), 0); + + let strip = Strip::new(2, 2, 0, true); + let clip_path = Some(vec![strip].into_boxed_slice()); + wide.push_layer( + 2, + clip_path, + BlendMode::default(), + None, + 0.09, + None, + Affine::IDENTITY, + &mut render_graph, + 0, + ); + + assert_eq!(wide.layer_stack.len(), 2); + assert_eq!(wide.clip_stack.len(), 1); + assert_eq!(wide.tiles[0].n_bufs, 0); + + wide.reset(); + + assert_eq!(wide.layer_stack.len(), 0); + assert_eq!(wide.clip_stack.len(), 0); + assert_eq!(wide.tiles[0].n_bufs, 0); + } + + #[test] + fn tiles_dirty_flag() { + type ClipPath = Option>; + + let mut wide = Wide::::new(256, 4); + let mut render_graph = RenderGraph::new(); + + assert!(!wide.tiles_dirty); + + let strips = [Strip::new(0, 0, 0, false), Strip::new(10, 0, 4, true)]; + wide.generate( + &strips, + Paint::Solid(PremulColor::from_alpha_color( + peniko::color::palette::css::RED, + )), + BlendMode::default(), + 0, + None, + &[], + ); + assert!(wide.tiles_dirty); + + wide.reset(); + assert!(!wide.tiles_dirty); + + let no_clip: ClipPath = None; + wide.push_layer( + 1, + no_clip, + BlendMode::default(), + None, + 1.0, + None, + Affine::IDENTITY, + &mut render_graph, + 0, + ); + assert!(wide.tiles_dirty); + + wide.pop_layer(&mut render_graph); + assert!(wide.tiles_dirty); + + wide.reset(); + assert!(!wide.tiles_dirty); + + // Generating with empty strips does not need to mark it as dirty. + wide.generate( + &[], + Paint::Solid(PremulColor::from_alpha_color( + peniko::color::palette::css::RED, + )), + BlendMode::default(), + 0, + None, + &[], + ); + assert!(!wide.tiles_dirty); + } +} diff --git a/vendor/vello_common/src/encode.rs b/vendor/vello_common/src/encode.rs new file mode 100644 index 0000000000..5161fe021a --- /dev/null +++ b/vendor/vello_common/src/encode.rs @@ -0,0 +1,1354 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Paints for drawing shapes. + +use crate::TextureId; +use crate::blurred_rounded_rect::BlurredRoundedRectangle; +use crate::color::palette::css::BLACK; +use crate::color::{ColorSpaceTag, HueDirection, Srgb, gradient}; +use crate::geometry::RectU16; +use crate::kurbo::{Affine, Point, Vec2}; +use crate::math::{FloatExt, compute_erf7}; +use crate::paint::{Image, ImageSource, IndexedPaint, Paint, PremulColor, Tint}; +use crate::peniko::{ColorStop, ColorStops, Extend, Gradient, GradientKind, ImageQuality}; +use alloc::borrow::Cow; +use alloc::fmt::Debug; +use alloc::vec; +use alloc::vec::Vec; +#[cfg(not(feature = "multithreading"))] +use core::cell::OnceCell; +use core::hash::{Hash, Hasher}; +use fearless_simd::{Simd, SimdBase, SimdFloat, f32x4, f32x16, mask32x4, mask32x16}; +use peniko::color::cache_key::{BitEq, BitHash, CacheKey}; +use peniko::color::gradient_unpremultiplied; +use peniko::{ + ImageSampler, InterpolationAlphaSpace, LinearGradientPosition, RadialGradientPosition, + SweepGradientPosition, +}; +use smallvec::ToSmallVec; +// So we can just use `OnceCell` regardless of which feature is activated. +#[cfg(feature = "multithreading")] +use std::sync::OnceLock as OnceCell; + +use crate::simd::{Splat4thExt, element_wise_splat}; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +const DEGENERATE_THRESHOLD: f32 = 1.0e-6; +const NUDGE_VAL: f32 = 1.0e-7; +#[cfg(feature = "std")] +fn exp(val: f32) -> f32 { + val.exp() +} + +#[cfg(not(feature = "std"))] +fn exp(val: f32) -> f32 { + #[cfg(feature = "libm")] + return libm::expf(val); + #[cfg(not(feature = "libm"))] + compile_error!("vello_common requires either the `std` or `libm` feature"); +} + +/// A trait for encoding paints. +pub trait EncodeExt: private::Sealed { + /// Encode the paint and push it into a vector of encoded paints, returning + /// the corresponding paint in the process. This will also validate the paint. + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + tint: Option, + ) -> Paint; +} + +impl EncodeExt for Gradient { + /// Encode the gradient into a paint. + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + _tint: Option, + ) -> Paint { + // First make sure that the gradient is valid and not degenerate. + if let Err(paint) = validate(self) { + return paint; + } + + let mut may_have_transparency = self.stops.iter().any(|s| s.color.components[3] != 1.0); + + let mut base_transform; + + let mut stops = Cow::Borrowed(&self.stops.0); + + let first_stop = &stops[0]; + let last_stop = &stops[stops.len() - 1]; + + if first_stop.offset != 0.0 || last_stop.offset != 1.0 { + let mut vec = stops.to_smallvec(); + + if first_stop.offset != 0.0 { + let mut first_stop = *first_stop; + first_stop.offset = 0.0; + vec.insert(0, first_stop); + } + + if last_stop.offset != 1.0 { + let mut last_stop = *last_stop; + last_stop.offset = 1.0; + vec.push(last_stop); + } + + stops = Cow::Owned(vec); + } + + let kind = match self.kind { + GradientKind::Linear(LinearGradientPosition { start: p0, end: p1 }) => { + // We update the transform currently in-place, such that the gradient line always + // starts at the point (0, 0) and ends at the point (1, 0). This simplifies the + // calculation for the current position along the gradient line a lot. + base_transform = ts_from_line_to_line(p0, p1, Point::ZERO, Point::new(1.0, 0.0)); + + EncodedKind::Linear(LinearKind) + } + GradientKind::Radial(RadialGradientPosition { + start_center: c0, + start_radius: r0, + end_center: c1, + end_radius: r1, + }) => { + // The implementation of radial gradients is translated from Skia. + // See: + // - + // - + // - + let d_radius = r1 - r0; + + // + let radial_kind = if ((c1 - c0).length() as f32).is_nearly_zero() { + base_transform = Affine::translate((-c1.x, -c1.y)); + base_transform = base_transform.then_scale(1.0 / r0.max(r1) as f64); + + let scale = r1.max(r0) / d_radius; + let bias = -r0 / d_radius; + + RadialKind::Radial { bias, scale } + } else { + base_transform = + ts_from_line_to_line(c0, c1, Point::ZERO, Point::new(1.0, 0.0)); + + if (r1 - r0).is_nearly_zero() { + let scaled_r0 = r1 / (c1 - c0).length() as f32; + RadialKind::Strip { + scaled_r0_squared: scaled_r0 * scaled_r0, + } + } else { + let d_center = (c0 - c1).length() as f32; + + let focal_data = + FocalData::create(r0 / d_center, r1 / d_center, &mut base_transform); + + let fp0 = 1.0 / focal_data.fr1; + let fp1 = focal_data.f_focal_x; + + RadialKind::Focal { + focal_data, + fp0, + fp1, + } + } + }; + + // Even if the gradient has no stops with transparency, we might have to force + // alpha-compositing in case the radial gradient is undefined in certain positions, + // in which case the resulting color will be transparent and thus the gradient overall + // must be treated as non-opaque. + may_have_transparency |= radial_kind.has_undefined(); + + EncodedKind::Radial(radial_kind) + } + GradientKind::Sweep(SweepGradientPosition { + center, + start_angle, + end_angle, + }) => { + // Make sure the center of the gradient falls on the origin (0, 0), to make + // angle calculation easier. + let x_offset = -center.x as f32; + let y_offset = -center.y as f32; + base_transform = Affine::translate((x_offset as f64, y_offset as f64)); + + EncodedKind::Sweep(SweepKind { + start_angle, + // Save the inverse so that we can use a multiplication in the shader instead. + inv_angle_delta: 1.0 / (end_angle - start_angle), + }) + } + }; + + let ranges = encode_stops( + &stops, + self.interpolation_cs, + self.hue_direction, + self.interpolation_alpha_space, + ); + + // This represents the transform that needs to be applied to the starting point of a + // command before starting with the rendering. + // First we need to account for the base transform of the shader, then + // we need to apply the _inverse_ paint transform to the point so that we can account + // for the paint transform of the render context. + let transform = base_transform * transform.inverse(); + + // One possible approach of calculating the positions would be to apply the above + // transform to _each_ pixel that we render in the wide tile. However, a much better + // approach is to apply the transform once for the first pixel in each wide tile, + // and from then on only apply incremental updates to the current x/y position + // that we calculate based on the transform. + // + // Remember that we render wide tiles in column major order (i.e. we first calculate the + // values for a specific x for all Tile::HEIGHT y by incrementing y by 1, and then finally + // we increment the x position by 1 and start from the beginning). If we want to implement + // the above approach of incrementally updating the position, we need to calculate + // how the x/y unit vectors are affected by the transform, and then use this as the + // step delta for a step in the x/y direction. + let (x_advance, y_advance) = x_y_advances(&transform); + + let cache_key = CacheKey(GradientCacheKey { + stops: self.stops.clone(), + interpolation_cs: self.interpolation_cs, + hue_direction: self.hue_direction, + }); + + let has_undefined = kind.has_undefined(); + + let encoded = EncodedGradient { + cache_key, + kind, + has_undefined, + transform, + x_advance, + y_advance, + ranges, + extend: self.extend, + may_have_transparency, + u8_lut: OnceCell::new(), + f32_lut: OnceCell::new(), + }; + + let idx = paints.len(); + paints.push(encoded.into()); + + Paint::Indexed(IndexedPaint::new(idx)) + } +} + +/// Returns a fallback paint in case the gradient is invalid. +/// +/// The paint will be either black or contain the color of the first stop of the gradient. +fn validate(gradient: &Gradient) -> Result<(), Paint> { + let black = Err(BLACK.into()); + + // Gradients need at least two stops. + if gradient.stops.is_empty() { + return black; + } + + let first = Err(gradient.stops[0].color.to_alpha_color::().into()); + + if gradient.stops.len() == 1 { + return first; + } + + for stops in gradient.stops.windows(2) { + let f = stops[0]; + let n = stops[1]; + + // Offsets must be between 0 and 1, and not NaN. + if !(0.0..=1.0).contains(&f.offset) { + return first; + } + + // Stops must be sorted by ascending offset. + if f.offset > n.offset { + return first; + } + } + + // Check the last stop as well. + let last = gradient.stops.last().unwrap(); + if !(0.0..=1.0).contains(&last.offset) { + return first; + } + + let degenerate_point = |p1: &Point, p2: &Point| { + (p1.x - p2.x).abs() as f32 <= DEGENERATE_THRESHOLD + && (p1.y - p2.y).abs() as f32 <= DEGENERATE_THRESHOLD + }; + + let degenerate_val = |v1: f32, v2: f32| (v2 - v1).abs() <= DEGENERATE_THRESHOLD; + + match &gradient.kind { + GradientKind::Linear(LinearGradientPosition { start, end }) => { + // Start and end points must not be too close together. + if degenerate_point(start, end) { + return first; + } + } + GradientKind::Radial(RadialGradientPosition { + start_center, + start_radius, + end_center, + end_radius, + }) => { + // Radii must not be negative. + if *start_radius < 0.0 || *end_radius < 0.0 { + return first; + } + + // Radii and center points must not be close to the same. + if degenerate_point(start_center, end_center) + && degenerate_val(*start_radius, *end_radius) + { + return first; + } + } + GradientKind::Sweep(SweepGradientPosition { + start_angle, + end_angle, + .. + }) => { + // The end angle must be larger than the start angle. + if degenerate_val(*start_angle, *end_angle) { + return first; + } + + if end_angle <= start_angle { + return first; + } + } + } + + Ok(()) +} + +/// Encode all stops into a sequence of ranges. +fn encode_stops( + stops: &[ColorStop], + cs: ColorSpaceTag, + hue_dir: HueDirection, + interpolation_alpha_space: InterpolationAlphaSpace, +) -> Vec { + #[derive(Debug)] + struct EncodedColorStop { + offset: f32, + color: crate::color::AlphaColor, + } + + let create_range = |left_stop: &EncodedColorStop, right_stop: &EncodedColorStop| { + let clamp = |mut color: [f32; 4]| { + // The linear approximation of the gradient can produce values slightly outside of + // [0.0, 1.0], so clamp them. + for c in &mut color { + *c = c.clamp(0.0, 1.0); + } + + color + }; + + let x0 = left_stop.offset; + let x1 = right_stop.offset; + let c0 = if interpolation_alpha_space == InterpolationAlphaSpace::Unpremultiplied { + clamp(left_stop.color.components) + } else { + clamp(left_stop.color.premultiply().components) + }; + let c1 = if interpolation_alpha_space == InterpolationAlphaSpace::Unpremultiplied { + clamp(right_stop.color.components) + } else { + clamp(right_stop.color.premultiply().components) + }; + + // We calculate a bias and scale factor, such that we can simply calculate + // bias + x * scale to get the interpolated color, where x is between x0 and x1, + // to calculate the resulting color. + // Apply a nudge value because we sometimes call `create_range` with the same offset + // to create the padded stops. + let x1_minus_x0 = (x1 - x0).max(NUDGE_VAL); + let mut scale = [0.0; 4]; + let mut bias = c0; + + for i in 0..4 { + scale[i] = (c1[i] - c0[i]) / x1_minus_x0; + bias[i] = c0[i] - x0 * scale[i]; + } + + GradientRange { + x1, + bias, + scale, + interpolation_alpha_space, + } + }; + + // Create additional (SRGB-encoded) stops in-between to approximate the color space we want to + // interpolate in. + if cs != ColorSpaceTag::Srgb { + let interpolated_stops = if interpolation_alpha_space + == InterpolationAlphaSpace::Premultiplied + { + stops + .windows(2) + .flat_map(|s| { + let left_stop = &s[0]; + let right_stop = &s[1]; + + let interpolated = + gradient::(left_stop.color, right_stop.color, cs, hue_dir, 0.01); + + interpolated.map(|st| EncodedColorStop { + offset: left_stop.offset + (right_stop.offset - left_stop.offset) * st.0, + color: st.1.un_premultiply(), + }) + }) + .collect::>() + } else { + stops + .windows(2) + .flat_map(|s| { + let left_stop = &s[0]; + let right_stop = &s[1]; + + let interpolated = gradient_unpremultiplied::( + left_stop.color, + right_stop.color, + cs, + hue_dir, + 0.01, + ); + + interpolated.map(|st| EncodedColorStop { + offset: left_stop.offset + (right_stop.offset - left_stop.offset) * st.0, + color: st.1, + }) + }) + .collect::>() + }; + + interpolated_stops + .windows(2) + .map(|s| { + let left_stop = &s[0]; + let right_stop = &s[1]; + + create_range(left_stop, right_stop) + }) + .collect() + } else { + stops + .windows(2) + .map(|c| { + let c0 = EncodedColorStop { + offset: c[0].offset, + color: c[0].color.to_alpha_color::(), + }; + + let c1 = EncodedColorStop { + offset: c[1].offset, + color: c[1].color.to_alpha_color::(), + }; + + create_range(&c0, &c1) + }) + .collect() + } +} + +pub(crate) fn x_y_advances(transform: &Affine) -> (Vec2, Vec2) { + let scale_skew_transform = { + let c = transform.as_coeffs(); + Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]) + }; + + let x_advance = scale_skew_transform * Point::new(1.0, 0.0); + let y_advance = scale_skew_transform * Point::new(0.0, 1.0); + + ( + Vec2::new(x_advance.x, x_advance.y), + Vec2::new(y_advance.x, y_advance.y), + ) +} + +impl private::Sealed for Image {} + +impl EncodeExt for Image { + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + tint: Option, + ) -> Paint { + let idx = paints.len(); + + let mut sampler = self.sampler; + + if sampler.alpha != 1.0 { + // If the sampler alpha is not 1.0, we need to force alpha compositing. + unimplemented!("Applying opacity to image commands"); + } + + let c = transform.as_coeffs(); + + // Optimize image quality for integer-only translations. + if (c[0] as f32 - 1.0).is_nearly_zero() + && (c[1] as f32).is_nearly_zero() + && (c[2] as f32).is_nearly_zero() + && (c[3] as f32 - 1.0).is_nearly_zero() + && ((c[4] - c[4].floor()) as f32).is_nearly_zero() + && ((c[5] - c[5].floor()) as f32).is_nearly_zero() + && sampler.quality == ImageQuality::Medium + { + sampler.quality = ImageQuality::Low; + } + + let transform = transform.inverse(); + + let (x_advance, y_advance) = x_y_advances(&transform); + + // If the tint color has alpha < 1.0, the image will have opacities + // even if the source pixels are all opaque. + let tint_has_opacity = tint.as_ref().is_some_and(|t| t.color.components[3] < 1.0); + + let encoded = EncodedImage { + may_have_transparency: self.image.may_have_transparency() || tint_has_opacity, + source: self.image.clone(), + sampler, + transform, + x_advance, + y_advance, + tint, + }; + + paints.push(EncodedPaint::Image(encoded)); + + Paint::Indexed(IndexedPaint::new(idx)) + } +} + +/// An encoded paint. +#[derive(Debug)] +pub enum EncodedPaint { + /// An encoded gradient. + Gradient(EncodedGradient), + /// An encoded image. + Image(EncodedImage), + /// An encoded external texture. + ExternalTexture(EncodedExternalTexture), + /// A blurred, rounded rectangle. + BlurredRoundedRect(EncodedBlurredRoundedRectangle), +} + +impl From for EncodedPaint { + fn from(value: EncodedGradient) -> Self { + Self::Gradient(value) + } +} + +impl From for EncodedPaint { + fn from(value: EncodedBlurredRoundedRectangle) -> Self { + Self::BlurredRoundedRect(value) + } +} + +/// An encoded image. +#[derive(Debug)] +pub struct EncodedImage { + /// The underlying pixmap of the image. + pub source: ImageSource, + /// Sampler + pub sampler: ImageSampler, + /// Whether the image has opacities. + pub may_have_transparency: bool, + /// A transform to apply to the image. + pub transform: Affine, + /// The advance in image coordinates for one step in the x direction. + pub x_advance: Vec2, + /// The advance in image coordinates for one step in the y direction. + pub y_advance: Vec2, + /// Optional tint applied to the image. + pub tint: Option, +} + +/// An encoded external texture. +/// +/// The texture must be bound by the user at render-time in order for us to be able to sample from +/// it; it is not interned into the renderer. +#[derive(Debug)] +pub struct EncodedExternalTexture { + /// External texture handle. + pub texture_id: TextureId, + /// Source region of the texture in texel coordinates. + pub source_region: RectU16, + /// Sampler parameters. + pub sampler: ImageSampler, + /// Whether the sampled content may contain non-opaque pixels. + pub may_have_transparency: bool, + /// Inverse destination transform, mapping scene coordinates to local source-rect space. + pub transform: Affine, + /// Optional tint applied to the sampled color. + pub tint: Option, +} + +/// Computed properties of a linear gradient. +#[derive(Debug, Copy, Clone)] +pub struct LinearKind; + +/// Focal data for a radial gradient. +#[derive(Debug, PartialEq, Copy, Clone)] +pub struct FocalData { + /// The normalized radius of the outer circle in focal space. + pub fr1: f32, + /// The x-coordinate of the focal point in normalized space \[0,1\]. + pub f_focal_x: f32, + /// Whether the focal points have been swapped. + pub f_is_swapped: bool, +} + +impl FocalData { + /// Create a new `FocalData` with the given radii and update the matrix. + pub fn create(mut r0: f32, mut r1: f32, matrix: &mut Affine) -> Self { + let mut swapped = false; + let mut f_focal_x = r0 / (r0 - r1); + + if (f_focal_x - 1.0).is_nearly_zero() { + *matrix = matrix.then_translate(Vec2::new(-1.0, 0.0)); + *matrix = matrix.then_scale_non_uniform(-1.0, 1.0); + core::mem::swap(&mut r0, &mut r1); + f_focal_x = 0.0; + swapped = true; + } + + let focal_matrix = ts_from_line_to_line( + Point::new(f_focal_x as f64, 0.0), + Point::new(1.0, 0.0), + Point::new(0.0, 0.0), + Point::new(1.0, 0.0), + ); + *matrix = focal_matrix * *matrix; + + let fr1 = r1 / (1.0 - f_focal_x).abs(); + + let data = Self { + fr1, + f_focal_x, + f_is_swapped: swapped, + }; + + if data.is_focal_on_circle() { + *matrix = matrix.then_scale(0.5); + } else { + *matrix = matrix.then_scale_non_uniform( + (fr1 / (fr1 * fr1 - 1.0)) as f64, + 1.0 / (fr1 * fr1 - 1.0).abs().sqrt() as f64, + ); + } + + *matrix = matrix.then_scale((1.0 - f_focal_x).abs() as f64); + + data + } + + /// Whether the focal is on the circle. + pub fn is_focal_on_circle(&self) -> bool { + (1.0 - self.fr1).is_nearly_zero() + } + + /// Whether the focal points have been swapped. + pub fn is_swapped(&self) -> bool { + self.f_is_swapped + } + + /// Whether the gradient is well-behaved. + pub fn is_well_behaved(&self) -> bool { + !self.is_focal_on_circle() && self.fr1 > 1.0 + } + + /// Whether the gradient is natively focal. + pub fn is_natively_focal(&self) -> bool { + self.f_focal_x.is_nearly_zero() + } +} + +/// A radial gradient. +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum RadialKind { + /// A radial gradient, i.e. the start and end center points are the same. + Radial { + /// The `bias` value (from the Skia implementation). + /// + /// It is a correction factor that accounts for the fact that the focal center might not + /// lie on the inner circle (if r0 > 0). + bias: f32, + /// The `scale` value (from the Skia implementation). + /// + /// It is a scaling factor that maps from r0 to r1. + scale: f32, + }, + /// A strip gradient, i.e. the start and end radius are the same. + Strip { + /// The squared value of `scaled_r0` (from the Skia implementation). + scaled_r0_squared: f32, + }, + /// A general, two-point conical gradient. + Focal { + /// The focal data (from the Skia implementation). + focal_data: FocalData, + /// The `fp0` value (from the Skia implementation). + fp0: f32, + /// The `fp1` value (from the Skia implementation). + fp1: f32, + }, +} + +impl RadialKind { + /// Whether the gradient is undefined at any location. + pub fn has_undefined(&self) -> bool { + match self { + Self::Radial { .. } => false, + Self::Strip { .. } => true, + Self::Focal { focal_data, .. } => !focal_data.is_well_behaved(), + } + } +} + +/// Computed properties of a sweep gradient. +#[derive(Debug)] +pub struct SweepKind { + /// The start angle of the sweep gradient. + pub start_angle: f32, + /// The inverse delta between start and end angle. + pub inv_angle_delta: f32, +} + +/// A kind of encoded gradient. +#[derive(Debug)] +pub enum EncodedKind { + /// An encoded linear gradient. + Linear(LinearKind), + /// An encoded radial gradient. + Radial(RadialKind), + /// An encoded sweep gradient. + Sweep(SweepKind), +} + +impl EncodedKind { + /// Whether the gradient is undefined at any location. + fn has_undefined(&self) -> bool { + match self { + Self::Radial(radial_kind) => radial_kind.has_undefined(), + _ => false, + } + } +} + +/// An encoded gradient. +#[derive(Debug)] +pub struct EncodedGradient { + /// The cache key for the gradient. + pub cache_key: CacheKey, + /// The underlying kind of gradient. + pub kind: EncodedKind, + /// Whether the gradient can yield undefined `t` values at some locations. + pub has_undefined: bool, + /// A transform that needs to be applied to the position of the first processed pixel. + pub transform: Affine, + /// How much to advance into the x/y direction for one step in the x direction. + pub x_advance: Vec2, + /// How much to advance into the x/y direction for one step in the y direction. + pub y_advance: Vec2, + /// The color ranges of the gradient. + pub ranges: Vec, + /// The extend of the gradient. + pub extend: Extend, + /// Whether the gradient requires `source_over` compositing. + pub may_have_transparency: bool, + u8_lut: OnceCell>, + f32_lut: OnceCell>, +} + +impl EncodedGradient { + /// Get the lookup table for sampling u8-based gradient values. + pub fn u8_lut(&self, simd: S) -> &GradientLut { + self.u8_lut + .get_or_init(|| GradientLut::new(simd, &self.ranges)) + } + + /// Get the lookup table for sampling f32-based gradient values. + pub fn f32_lut(&self, simd: S) -> &GradientLut { + self.f32_lut + .get_or_init(|| GradientLut::new(simd, &self.ranges)) + } +} + +/// Cache key for gradient color ramps based on color-affecting properties. +#[derive(Debug, Clone)] +pub struct GradientCacheKey { + /// The color stops (offsets + colors). + pub stops: ColorStops, + /// Color space used for interpolation. + pub interpolation_cs: ColorSpaceTag, + /// Hue direction used for interpolation. + pub hue_direction: HueDirection, +} + +impl BitHash for GradientCacheKey { + fn bit_hash(&self, state: &mut H) { + self.stops.bit_hash(state); + core::mem::discriminant(&self.interpolation_cs).hash(state); + core::mem::discriminant(&self.hue_direction).hash(state); + } +} + +impl BitEq for GradientCacheKey { + fn bit_eq(&self, other: &Self) -> bool { + self.stops.bit_eq(&other.stops) + && self.interpolation_cs == other.interpolation_cs + && self.hue_direction == other.hue_direction + } +} + +/// An encoded range between two color stops. +#[derive(Debug, Clone)] +pub struct GradientRange { + /// The end value of the range. + pub x1: f32, + /// A bias to apply when interpolating the color (in this case just the values of the start + /// color of the gradient). + pub bias: [f32; 4], + /// The scale factors of the range. By calculating bias + x * factors (where x is + /// between 0.0 and 1.0), we can interpolate between start and end color of the gradient range. + pub scale: [f32; 4], + /// The alpha space in which the interpolation was performed. + pub interpolation_alpha_space: InterpolationAlphaSpace, +} + +/// An encoded blurred, rounded rectangle. +#[derive(Debug)] +pub struct EncodedBlurredRoundedRectangle { + /// An component for computing the blur effect. + pub exponent: f32, + /// An component for computing the blur effect. + pub recip_exponent: f32, + /// An component for computing the blur effect. + pub scale: f32, + /// An component for computing the blur effect. + pub std_dev_inv: f32, + /// An component for computing the blur effect. + pub min_edge: f32, + /// An component for computing the blur effect. + pub w: f32, + /// An component for computing the blur effect. + pub h: f32, + /// An component for computing the blur effect. + pub width: f32, + /// An component for computing the blur effect. + pub height: f32, + /// An component for computing the blur effect. + pub r1: f32, + /// The base color for the blurred rectangle. + pub color: PremulColor, + /// A transform that needs to be applied to the position of the first processed pixel. + pub transform: Affine, + /// How much to advance into the x/y direction for one step in the x direction. + pub x_advance: Vec2, + /// How much to advance into the x/y direction for one step in the y direction. + pub y_advance: Vec2, +} + +impl private::Sealed for BlurredRoundedRectangle {} + +impl EncodeExt for BlurredRoundedRectangle { + fn encode_into( + &self, + paints: &mut Vec, + transform: Affine, + _tint: Option, + ) -> Paint { + let rect = { + // Ensure rectangle has positive width/height. + let mut rect = self.rect; + + if self.rect.x0 > self.rect.x1 { + core::mem::swap(&mut rect.x0, &mut rect.x1); + } + + if self.rect.y0 > self.rect.y1 { + core::mem::swap(&mut rect.y0, &mut rect.y1); + } + + rect + }; + + let transform = Affine::translate((-rect.x0, -rect.y0)) * transform.inverse(); + + let (x_advance, y_advance) = x_y_advances(&transform); + + let width = rect.width() as f32; + let height = rect.height() as f32; + let radius = self.radius.min(0.5 * width.min(height)); + + // To avoid divide by 0; potentially should be a bigger number for antialiasing. + let std_dev = self.std_dev.max(1e-6); + + let min_edge = width.min(height); + let rmax = 0.5 * min_edge; + let r0 = radius.hypot(std_dev * 1.15).min(rmax); + let r1 = radius.hypot(std_dev * 2.0).min(rmax); + + let exponent = 2.0 * r1 / r0; + + let std_dev_inv = std_dev.recip(); + + // Pull in long end (make less eccentric). + let delta = 1.25 + * std_dev + * (exp(-(0.5 * std_dev_inv * width).powi(2)) + - exp(-(0.5 * std_dev_inv * height).powi(2))); + let w = width + delta.min(0.0); + let h = height - delta.max(0.0); + + let recip_exponent = exponent.recip(); + let scale = 0.5 * compute_erf7(std_dev_inv * 0.5 * (w.max(h) - 0.5 * radius)); + + let encoded = EncodedBlurredRoundedRectangle { + exponent, + recip_exponent, + width, + height, + scale, + r1, + std_dev_inv, + min_edge, + color: PremulColor::from_alpha_color(self.color), + w, + h, + transform, + x_advance, + y_advance, + }; + + let idx = paints.len(); + paints.push(encoded.into()); + + Paint::Indexed(IndexedPaint::new(idx)) + } +} + +/// Calculates the transform necessary to map the line spanned by points src1, src2 to +/// the line spanned by dst1, dst2. +/// +/// This creates a transformation that maps any line segment to any other line segment. +/// For gradients, we use this to transform the gradient line to a standard form (0,0) → (1,0). +/// +/// Copied from +fn ts_from_line_to_line(src1: Point, src2: Point, dst1: Point, dst2: Point) -> Affine { + let unit_to_line1 = unit_to_line(src1, src2); + // Calculate the transform necessary to map line1 to the unit vector. + let line1_to_unit = unit_to_line1.inverse(); + // Then map the unit vector to line2. + let unit_to_line2 = unit_to_line(dst1, dst2); + + unit_to_line2 * line1_to_unit +} + +/// Calculate the transform necessary to map the unit vector to the line spanned by the points +/// `p1` and `p2`. +fn unit_to_line(p0: Point, p1: Point) -> Affine { + Affine::new([ + p1.y - p0.y, + p0.x - p1.x, + p1.x - p0.x, + p1.y - p0.y, + p0.x, + p0.y, + ]) +} + +/// A helper trait for converting a premultiplied f32 color to `Self`. +pub trait FromF32Color: Sized + Debug + Copy + Clone { + /// The zero value. + const ZERO: Self; + /// Convert from a premultiplied f32 color to `Self`. + fn from_f32(color: f32x4) -> [Self; 4]; +} + +impl FromF32Color for f32 { + const ZERO: Self = 0.0; + + fn from_f32(color: f32x4) -> [Self; 4] { + color.into() + } +} + +impl FromF32Color for u8 { + const ZERO: Self = 0; + + fn from_f32(mut color: f32x4) -> [Self; 4] { + let simd = color.simd; + color = color.mul_add(f32x4::splat(simd, 255.0), f32x4::splat(simd, 0.5)); + + [ + color[0] as Self, + color[1] as Self, + color[2] as Self, + color[3] as Self, + ] + } +} + +/// A lookup table for sampled gradient values. +#[derive(Debug)] +pub struct GradientLut { + lut: Vec<[T; 4]>, + scale: f32, +} + +impl GradientLut { + /// Create a new lookup table. + fn new(simd: S, ranges: &[GradientRange]) -> Self { + let lut_size = determine_lut_size(ranges); + let mut lut = vec![[T::ZERO; 4]; lut_size]; + + // Calculate how many indices are covered by each range. + let ramps = { + let mut ramps = Vec::with_capacity(ranges.len()); + let mut prev_idx = 0; + + for range in ranges { + let max_idx = (range.x1 * lut_size as f32) as usize; + + ramps.push((prev_idx..max_idx, range)); + prev_idx = max_idx; + } + + ramps + }; + + let scale = lut_size as f32 - 1.0; + + let inv_lut_scale = f32x4::splat(simd, 1.0 / scale); + let add_factor = f32x4::from_slice(simd, &[0.0, 1.0, 2.0, 3.0]) * inv_lut_scale; + + for (ramp_range, range) in ramps { + let biases = f32x16::block_splat(f32x4::from_slice(simd, &range.bias)); + let scales = f32x16::block_splat(f32x4::from_slice(simd, &range.scale)); + + ramp_range.clone().step_by(4).for_each(|idx| { + let t_vals = f32x4::splat(simd, idx as f32).mul_add(inv_lut_scale, add_factor); + + let t_vals = element_wise_splat(simd, t_vals); + + let mut result = scales.mul_add(t_vals, biases); + let alphas = result.splat_4th(); + // Premultiply colors, since we did interpolation in unpremultiplied space. + if range.interpolation_alpha_space == InterpolationAlphaSpace::Unpremultiplied { + result = { + let mask = + mask32x16::block_splat(mask32x4::from_slice(simd, &[-1, -1, -1, 0])); + simd.select_f32x16(mask, result * alphas, alphas) + }; + } + + // Due to floating-point impreciseness, it can happen that + // values either become greater than 1 or the RGB channels + // become greater than the alpha channel. To prevent overflows + // in later parts of the pipeline, we need to take the minimum here. + result = result.min(1.0).min(alphas); + let (im1, im2) = simd.split_f32x16(result); + let (r1, r2) = simd.split_f32x8(im1); + let (r3, r4) = simd.split_f32x8(im2); + let rs = [r1, r2, r3, r4].map(T::from_f32); + + // We always compute 4 samples at a time, but a gradient ramp does not necessarily + // start at a multiple of 4, therefore we might have to truncate. + let lut = &mut lut[idx..(idx + 4).min(lut_size)]; + lut.copy_from_slice(&rs[..lut.len()]); + }); + } + + Self { lut, scale } + } + + /// Get the sample value at a specific index. + #[inline(always)] + pub fn get(&self, idx: usize) -> [T; 4] { + self.lut[idx] + } + + /// Return the raw array of gradient sample values. + #[inline(always)] + pub fn lut(&self) -> &[[T; 4]] { + &self.lut + } + + /// Return the number of entries in the lookup table. + #[inline(always)] + pub fn width(&self) -> usize { + self.lut.len() + } + + /// Get the scale factor by which to scale the parametric value to + /// compute the correct lookup index. + #[inline(always)] + pub fn scale_factor(&self) -> f32 { + self.scale + } +} + +/// The maximum size of the gradient LUT. +// Of course in theory we could still have a stop at 0.0001 in which case this resolution +// wouldn't be enough, but for all intents and purposes this should be more than sufficient +// for most real cases. +pub const MAX_GRADIENT_LUT_SIZE: usize = 4096; + +fn determine_lut_size(ranges: &[GradientRange]) -> usize { + // Inspired by Blend2D. + // By default: + // 256 for 2 stops. + // 512 for 3 stops. + // 1024 for 4 or more stops. + let stop_len = match ranges.len() { + 1 => 256, + 2 => 512, + _ => 1024, + }; + + // In case we have some tricky stops (for example 3 stops with 0.0, 0.001, 1.0), we might + // increase the resolution. + let mut last_x1 = 0.0; + let mut min_size = 0; + + for x1 in ranges.iter().map(|e| e.x1) { + // For example, if the first stop is at 0.001, then we need a resolution of at least 1000 + // so that we can still safely capture the first stop. + let res = ((1.0 / (x1 - last_x1)).ceil() as usize) + .min(MAX_GRADIENT_LUT_SIZE) + .next_power_of_two(); + min_size = min_size.max(res); + last_x1 = x1; + } + + // Take the maximum of both, but don't exceed `MAX_LEN`. + stop_len.max(min_size) +} + +mod private { + #[expect(unnameable_types, reason = "Sealed trait pattern.")] + pub trait Sealed {} + + impl Sealed for super::Gradient {} +} + +#[cfg(test)] +mod tests { + use super::{EncodeExt, Gradient}; + use crate::color::DynamicColor; + use crate::color::palette::css::{BLACK, BLUE, GREEN}; + use crate::kurbo::{Affine, Point}; + use crate::peniko::{ColorStop, ColorStops}; + use alloc::vec; + use peniko::{LinearGradientPosition, RadialGradientPosition}; + use smallvec::smallvec; + + #[test] + fn gradient_missing_stops() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + BLACK.into() + ); + } + + #[test] + fn gradient_one_stop() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }]), + ..Default::default() + }; + + // Should return the color of the first stop. + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_not_sorted_stops() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 1.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_linear_degenerate() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(0.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: 1.0, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_last_stop_with_infinity_offset() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: f32::INFINITY, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + // Invalid gradient, so fall back to first color. + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_stop_with_nan_offset() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: LinearGradientPosition { + start: Point::new(0.0, 0.0), + end: Point::new(20.0, 0.0), + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: f32::NAN, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + // Invalid gradient, so fall back to first color. + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } + + #[test] + fn gradient_radial_degenerate() { + let mut buf = vec![]; + + let gradient = Gradient { + kind: RadialGradientPosition { + start_center: Point::new(0.0, 0.0), + start_radius: 20.0, + end_center: Point::new(0.0, 0.0), + end_radius: 20.0, + } + .into(), + stops: ColorStops(smallvec![ + ColorStop { + offset: 0.0, + color: DynamicColor::from_alpha_color(GREEN), + }, + ColorStop { + offset: 1.0, + color: DynamicColor::from_alpha_color(BLUE), + }, + ]), + ..Default::default() + }; + + assert_eq!( + gradient.encode_into(&mut buf, Affine::IDENTITY, None), + GREEN.into() + ); + } +} diff --git a/vendor/vello_common/src/filter/drop_shadow.rs b/vendor/vello_common/src/filter/drop_shadow.rs new file mode 100644 index 0000000000..5260225c93 --- /dev/null +++ b/vendor/vello_common/src/filter/drop_shadow.rs @@ -0,0 +1,86 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The drop shadow filter. + +use crate::color::{AlphaColor, Srgb}; +use crate::filter::gaussian_blur::{MAX_KERNEL_SIZE, plan_decimated_blur, transform_blur_params}; +use crate::filter::transform_offset_params; +use crate::filter_effects::EdgeMode; +use crate::kurbo::Affine; + +/// A drop shadow filter. +#[derive(Debug)] +pub struct DropShadow { + /// The x-offset of the shadow. + pub dx: f32, + /// The y-offset of the shadow. + pub dy: f32, + /// The color of the shadow. + pub color: AlphaColor, + /// Standard deviation for the blur (for reference/debugging). + pub std_deviation: f32, + /// Edge mode for blur sampling. + pub edge_mode: EdgeMode, + /// Number of 2x2 decimation levels to use (0 means direct convolution). + pub n_decimations: usize, + /// Pre-computed Gaussian kernel weights for the reduced blur. + /// Only the first `kernel_size` elements are valid. + pub kernel: [f32; MAX_KERNEL_SIZE], + /// Actual length of the kernel (kernel is padded to `MAX_KERNEL_SIZE`). + pub kernel_size: u8, +} + +impl DropShadow { + /// Create a new drop shadow filter with the specified parameters. + /// + /// This precomputes the blur decimation plan and kernel for optimal performance. + pub fn new( + dx: f32, + dy: f32, + std_deviation: f32, + edge_mode: EdgeMode, + color: AlphaColor, + ) -> Self { + // Precompute blur plan (same logic as GaussianBlur::new) + let (n_decimations, kernel, kernel_size) = plan_decimated_blur(std_deviation); + + Self { + dx, + dy, + color, + std_deviation, + edge_mode, + n_decimations, + kernel, + kernel_size, + } + } +} + +/// Transform a drop shadow's offset and standard deviation using the affine transformation. +/// +/// Applies the full linear transformation (rotation, scale, and shear) to the offset vector, +/// and scales the blur standard deviation uniformly. +/// +/// # Arguments +/// * `dx` - Horizontal offset in user space +/// * `dy` - Vertical offset in user space +/// * `std_deviation` - Blur standard deviation in user space +/// * `transform` - The transformation matrix to apply +/// +/// # Returns +/// A tuple of (`scaled_dx`, `scaled_dy`, `scaled_std_dev`) in device space +pub(crate) fn transform_shadow_params( + dx: f32, + dy: f32, + std_deviation: f32, + transform: &Affine, +) -> (f32, f32, f32) { + let (scaled_dx, scaled_dy) = transform_offset_params(dx, dy, transform); + + // Scale the blur radius uniformly + let scaled_std_dev = transform_blur_params(std_deviation, transform); + + (scaled_dx, scaled_dy, scaled_std_dev) +} diff --git a/vendor/vello_common/src/filter/flood.rs b/vendor/vello_common/src/filter/flood.rs new file mode 100644 index 0000000000..638525a666 --- /dev/null +++ b/vendor/vello_common/src/filter/flood.rs @@ -0,0 +1,20 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The flood filter. + +use crate::color::{AlphaColor, Srgb}; + +/// A flood filter. +#[derive(Debug)] +pub struct Flood { + /// The flood color. + pub color: AlphaColor, +} + +impl Flood { + /// Create a new flood filter with the specified color. + pub fn new(color: AlphaColor) -> Self { + Self { color } + } +} diff --git a/vendor/vello_common/src/filter/gaussian_blur.rs b/vendor/vello_common/src/filter/gaussian_blur.rs new file mode 100644 index 0000000000..e31bfd509b --- /dev/null +++ b/vendor/vello_common/src/filter/gaussian_blur.rs @@ -0,0 +1,334 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The gaussian blur filter. + +use alloc::vec::Vec; + +use crate::filter_effects::EdgeMode; +use crate::kurbo::Affine; +use crate::util::extract_scales; +use core::f32::consts::E; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +/// Scale a blur's standard deviation uniformly based on the transformation. +/// +/// Extracts the scale factors from the transformation matrix using SVD and +/// averages them to get a uniform scale factor for the blur radius. +/// +/// # Arguments +/// * `std_deviation` - The blur standard deviation in user space +/// * `transform` - The transformation matrix to extract scale from +/// +/// # Returns +/// The scaled standard deviation in device space +pub(crate) fn transform_blur_params(std_deviation: f32, transform: &Affine) -> f32 { + let (scale_x, scale_y) = extract_scales(transform); + let uniform_scale = (scale_x + scale_y) / 2.0; + // TODO: Support separate std_deviation for x and y axes (std_deviation_x, std_deviation_y) + // to properly handle non-uniform scaling. This would eliminate the need for uniform_scale + // and allow blur to scale independently along each axis. + std_deviation * uniform_scale +} + +/// Maximum size of the Gaussian kernel (must be odd and equal to or smaller than [`u8::MAX`]). +/// +/// The multi-scale decimation algorithm guarantees that kernel size never exceeds this value. +/// Decimation stops when remaining variance ≤ 4.0 (σ ≤ 2.0), which produces kernels of size +/// at most 13 (radius = ceil(3σ) = 6, size = 1 + 2×6 = 13). +// Keep in sync with MAX_KERNEL_SIZE in vello_sparse_shaders/shaders/filters.wgsl +pub const MAX_KERNEL_SIZE: usize = 13; + +#[cfg(test)] +const _: () = const { + if MAX_KERNEL_SIZE.is_multiple_of(2) { + panic!("`MAX_KERNEL_SIZE` must be odd"); + } + if MAX_KERNEL_SIZE > u8::MAX as usize { + panic!("`MAX_KERNEL_SIZE` must be less than or equal to `u8::MAX`"); + } +}; + +/// A gaussian blur. +#[derive(Debug)] +pub struct GaussianBlur { + /// The standard deviation. + pub std_deviation: f32, + /// Number of 2× decimation levels to use (0 means no decimation, direct convolution). + pub n_decimations: usize, + /// Pre-computed Gaussian kernel weights for the reduced blur. + /// Only the first `kernel_size` elements are valid. + pub kernel: [f32; MAX_KERNEL_SIZE], + /// Actual length of the kernel (rest is padding up to `MAX_KERNEL_SIZE`). + pub kernel_size: u8, + /// Edge mode for handling out-of-bounds sampling. + pub edge_mode: EdgeMode, +} + +impl GaussianBlur { + /// Create a new Gaussian blur filter with the specified standard deviation. + /// + /// This precomputes the decimation plan, kernel, and radius for optimal performance. + pub fn new(std_deviation: f32, edge_mode: EdgeMode) -> Self { + let (n_decimations, kernel, kernel_size) = plan_decimated_blur(std_deviation); + + Self { + std_deviation, + edge_mode, + n_decimations, + kernel, + kernel_size, + } + } +} + +/// Compute the blur execution plan based on standard deviation. +/// +/// Returns (`n_decimations`, `kernel`, `kernel_size`): +/// - `n_decimations`: Number of 2× downsampling steps to perform (per axis) +/// - `kernel`: Pre-computed Gaussian kernel weights (fixed-size array) +/// - `kernel_size`: Actual length of the kernel (rest is zero-padded) +pub fn plan_decimated_blur(std_deviation: f32) -> (usize, [f32; MAX_KERNEL_SIZE], u8) { + if std_deviation <= 0.0 { + // Invalid standard deviation, return identity kernel (no blur) + let mut kernel = [0.0; MAX_KERNEL_SIZE]; + kernel[0] = 1.0; + return (0, kernel, 1); + } + + // Compute decimation plan using variance analysis. + // Variance (σ²) has the additive property: applying two blurs sequentially + // adds their variances together. We use this to decompose the blur. + // + // Mathematical Foundation: From probability theory, convolving two Gaussians + // G(σ₁) ⊗ G(σ₂) = G(√(σ₁² + σ₂²)). This means variance is additive: σ²_total = σ²_1 + σ²_2. + // Rearranging: σ²_2 = σ²_total - σ²_1, allowing us to decompose the target blur. + let variance = std_deviation * std_deviation; + let mut n_decimations = 0; + let mut remaining_variance = variance; + + // Each decimation step: + // 1. Applies [1,3,3,1] blur which adds variance = 0.75 per axis + // 2. Downsamples by 2× per axis, adding variance = 0.75 × 4 = 3.0 total + // 3. The 2× downsampling scales the remaining variance by 0.25 (= 1/2²) + // Stop when remaining variance ≤ 4.0 (σ ≈ 2), as further decimation isn't beneficial. + while remaining_variance > 4.0 { + remaining_variance = (remaining_variance - 3.0) * 0.25; + n_decimations += 1; + } + // Compute the reduced standard deviation to apply at the decimated resolution + let remaining_sigma = remaining_variance.sqrt(); + // Compute Gaussian kernel for the reduced blur + let (kernel, kernel_size) = compute_gaussian_kernel(remaining_sigma); + + (n_decimations, kernel, kernel_size) +} + +/// Compute 1D Gaussian kernel weights for separable convolution. +/// +/// Returns (`kernel_weights`, `kernel_size`) where `kernel_size = 2×radius + 1`. +/// The kernel is stored in a fixed-size array to avoid heap allocation. +/// Uses the standard Gaussian formula: G(x) = exp(-x² / (2σ²)), normalized to sum to 1. +pub fn compute_gaussian_kernel(std_deviation: f32) -> ([f32; MAX_KERNEL_SIZE], u8) { + // Use radius = 3σ to capture 99.7% of the Gaussian distribution. + // Beyond ±3σ, the Gaussian values are negligible (<0.3%). + let radius = (3.0 * std_deviation).ceil() as usize; + let kernel_size = (1 + radius * 2).min(MAX_KERNEL_SIZE) as u8; + + let mut kernel = [0.0; MAX_KERNEL_SIZE]; + // Compute Gaussian weights using the formula: G(x) = exp(-x² / (2σ²)) + // This creates a symmetric bell curve centered at the middle of the kernel. + let gaussian_denominator = 2.0 * std_deviation * std_deviation; + let mut sum = 0.0; + let kernel_center = (kernel_size / 2) as f32; + for (i, weight) in kernel.iter_mut().enumerate().take(usize::from(kernel_size)) { + // Compute distance from center (0 at center, increases outward) + let x = (i as f32) - kernel_center; + // Apply Gaussian formula: weight decreases exponentially with squared distance + *weight = E.powf(-x * x / gaussian_denominator); + sum += *weight; + } + + // Normalize weights to sum to 1.0, ensuring the blur doesn't change overall brightness. + // Without normalization, blurring a uniform gray area could make it brighter/darker. + let scale = 1.0 / sum; + for weight in kernel.iter_mut().take(usize::from(kernel_size)) { + *weight *= scale; + } + + (kernel, kernel_size) +} + +/// Tracks dimensions through a chain of downscale/upscale operations. +#[derive(Debug, Default)] +pub struct DecimationSizer { + width: u16, + height: u16, + dim_stack: Vec<(u16, u16)>, +} + +impl DecimationSizer { + /// Create a new sizer with the given initial dimensions. + #[inline] + pub fn new(width: u16, height: u16) -> Self { + Self { + width, + height, + dim_stack: Vec::new(), + } + } + + /// Reset the sizer so it can be reused. + #[inline] + pub fn reset(&mut self, width: u16, height: u16) { + self.width = width; + self.height = height; + self.dim_stack.clear(); + } + + /// Returns the current logical dimensions. + #[inline] + pub fn current(&self) -> (u16, u16) { + (self.width, self.height) + } + + /// Apply a new downscale operation. + #[inline] + pub fn downscale(&mut self) -> (u16, u16) { + self.dim_stack.push((self.width, self.height)); + self.width = self.width.div_ceil(2); + self.height = self.height.div_ceil(2); + (self.width, self.height) + } + + /// Apply a new upscale operation. + #[inline] + pub fn upscale(&mut self) -> (u16, u16) { + let (target_w, target_h) = self.dim_stack.pop().unwrap(); + // Clamp because upscale can exceed target on odd dimensions (e.g., 5→3→6 > 5) + self.width = (self.width * 2).min(target_w); + self.height = (self.height * 2).min(target_h); + (self.width, self.height) + } +} + +#[cfg(test)] +mod tests { + use crate::filter::gaussian_blur::{ + DecimationSizer, compute_gaussian_kernel, plan_decimated_blur, + }; + + /// Test Gaussian kernel computation for small σ. + #[test] + fn test_gaussian_kernel_small_sigma() { + let (kernel, size) = compute_gaussian_kernel(1.0); + // For σ=1.0, radius = ceil(3.0) = 3, size = 2*3+1 = 7 + assert_eq!(size, 7); + + // Kernel should be symmetric + for i in 0..size / 2 { + assert!((kernel[usize::from(i)] - kernel[usize::from(size - 1 - i)]).abs() < 1e-6); + } + + // Kernel should sum to 1.0 (normalized) + let sum: f32 = kernel.iter().take(usize::from(size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + + // Center should be the largest weight + let center_idx = size / 2; + for i in 0..size { + if i != center_idx { + assert!(kernel[usize::from(center_idx)] >= kernel[usize::from(i)]); + } + } + } + + /// Test Gaussian kernel computation for very small σ (near-zero). + #[test] + fn test_gaussian_kernel_very_small_sigma() { + let (kernel, size) = compute_gaussian_kernel(0.1); + // For σ=0.1, radius = ceil(0.3) = 1, size = 3 + assert_eq!(size, 3); + // Should sum to 1.0 + let sum: f32 = kernel.iter().take(usize::from(size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + // Center weight should be dominant for very small σ + assert!(kernel[1] > 0.9); // Center is highly weighted + } + + /// Test Gaussian kernel for fractional σ. + #[test] + fn test_gaussian_kernel_fractional_sigma() { + let (kernel, size) = compute_gaussian_kernel(0.5); + // For σ=0.5, radius = ceil(1.5) = 2, size = 5 + assert_eq!(size, 5); + + // Should still sum to 1.0 + let sum: f32 = kernel.iter().take(usize::from(size)).sum(); + assert!((sum - 1.0).abs() < 1e-6); + } + + /// Test decimation plan for small blur (no decimation). + #[test] + fn test_plan_no_decimation() { + let (n_decimations, _kernel, _size) = plan_decimated_blur(1.0); + // σ=1.0 → variance=1.0, should not decimate + assert_eq!(n_decimations, 0); + } + + /// Test decimation plan for medium blur (some decimation). + #[test] + fn test_plan_with_decimation() { + let (n_decimations, _kernel, _size) = plan_decimated_blur(5.0); + // σ=5.0 → variance=25.0, should decimate + assert_eq!(n_decimations, 2); + } + + /// Test decimation plan at boundary (σ=2.0). + #[test] + fn test_plan_decimation_boundary() { + let (n_decimations, _kernel, _size) = plan_decimated_blur(2.0); + // σ=2.0 → variance=4.0, right at the boundary + assert_eq!(n_decimations, 0); + } + + /// Test decimation plan for negative σ (invalid, should return identity). + #[test] + fn test_plan_negative_sigma() { + let (n_decimations, kernel, size) = plan_decimated_blur(-1.0); + assert_eq!(n_decimations, 0); + assert_eq!(size, 1); + assert!((kernel[0] - 1.0).abs() < 1e-6); + } + + #[test] + fn test_decimation_sizer_even() { + let mut sizer = DecimationSizer::new(8, 8); + assert_eq!(sizer.current(), (8, 8)); + + assert_eq!(sizer.downscale(), (4, 4)); + assert_eq!(sizer.downscale(), (2, 2)); + + assert_eq!(sizer.upscale(), (4, 4)); + assert_eq!(sizer.upscale(), (8, 8)); + } + + #[test] + fn test_decimation_sizer_odd() { + let mut sizer = DecimationSizer::new(5, 7); + assert_eq!(sizer.downscale(), (3, 4)); + assert_eq!(sizer.downscale(), (2, 2)); + + // Upscale clamps to the pre-downscale target + assert_eq!(sizer.upscale(), (3, 4)); + assert_eq!(sizer.upscale(), (5, 7)); + } + + #[test] + fn test_decimation_sizer_single_level() { + let mut sizer = DecimationSizer::new(100, 50); + assert_eq!(sizer.downscale(), (50, 25)); + assert_eq!(sizer.upscale(), (100, 50)); + } +} diff --git a/vendor/vello_common/src/filter/mod.rs b/vendor/vello_common/src/filter/mod.rs new file mode 100644 index 0000000000..4da635d61a --- /dev/null +++ b/vendor/vello_common/src/filter/mod.rs @@ -0,0 +1,94 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Common filter helper functions. +//! +//! Unlike the filters defines in [`crate::filter_effects`], the filters in this module +//! represent a special representation of each filter to be used as the basis for rendering in +//! `vello_hybrid` and `vello_cpu`. + +use crate::filter::drop_shadow::{DropShadow, transform_shadow_params}; +use crate::filter::flood::Flood; +use crate::filter::gaussian_blur::{GaussianBlur, transform_blur_params}; +use crate::filter::offset::Offset; +use crate::filter_effects::{Filter, FilterPrimitive}; +use crate::kurbo::{Affine, Vec2}; + +pub mod drop_shadow; +pub mod flood; +pub mod gaussian_blur; +pub mod offset; + +/// A filter that has been prepared for rendering. +#[derive(Debug)] +pub enum PreparedFilter { + /// A flood filter. + Flood(Flood), + /// A gaussian blur filter. + GaussianBlur(GaussianBlur), + /// An offset filter. + Offset(Offset), + /// A drop shadow filter. + DropShadow(DropShadow), +} + +impl PreparedFilter { + /// Build a new prepared filter for the given transform. + pub fn new(filter: &Filter, transform: &Affine) -> Self { + // Multi-primitive filter graphs are not yet implemented. + if filter.graph.primitives.len() != 1 { + unimplemented!("Multi-primitive filter graphs are not yet supported"); + } + + match &filter.graph.primitives[0] { + FilterPrimitive::Flood { color } => { + let flood = Flood::new(*color); + Self::Flood(flood) + } + FilterPrimitive::GaussianBlur { + std_deviation, + edge_mode, + } => { + let scaled_std_dev = transform_blur_params(*std_deviation, transform); + let blur = GaussianBlur::new(scaled_std_dev, *edge_mode); + Self::GaussianBlur(blur) + } + FilterPrimitive::DropShadow { + dx, + dy, + std_deviation, + color, + edge_mode, + } => { + let (scaled_dx, scaled_dy, scaled_std_dev) = + transform_shadow_params(*dx, *dy, *std_deviation, transform); + let drop_shadow = + DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color); + + Self::DropShadow(drop_shadow) + } + FilterPrimitive::Offset { dx, dy } => { + let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, transform); + let offset = Offset::new(scaled_dx, scaled_dy); + + Self::Offset(offset) + } + _ => { + // Other primitives like Blend, ColorMatrix, ComponentTransfer, etc. + // are not yet implemented + unimplemented!("Other filter primitives not yet implemented"); + } + } + } +} + +/// Transform an offset's dx/dy using the affine transformation's linear part. +/// +/// # Returns +/// A tuple of (`scaled_dx`, `scaled_dy`) in device space. +fn transform_offset_params(dx: f32, dy: f32, transform: &Affine) -> (f32, f32) { + let offset = Vec2::new(dx as f64, dy as f64); + let [a, b, c, d, _, _] = transform.as_coeffs(); + let transformed_offset = Vec2::new(a * offset.x + c * offset.y, b * offset.x + d * offset.y); + (transformed_offset.x as f32, transformed_offset.y as f32) +} diff --git a/vendor/vello_common/src/filter/offset.rs b/vendor/vello_common/src/filter/offset.rs new file mode 100644 index 0000000000..3ac7d5a27c --- /dev/null +++ b/vendor/vello_common/src/filter/offset.rs @@ -0,0 +1,22 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! The offset filter. + +/// Translation/shift filter. +/// +/// This shifts the input image by `(dx, dy)` in device pixel space. +#[derive(Clone, Copy, Debug)] +pub struct Offset { + /// The x-offset that should be applied. + pub dx: f32, + /// The y-offset that should be applied. + pub dy: f32, +} + +impl Offset { + /// Create a new offset filter. + pub fn new(dx: f32, dy: f32) -> Self { + Self { dx, dy } + } +} diff --git a/vendor/vello_common/src/filter_effects.rs b/vendor/vello_common/src/filter_effects.rs new file mode 100644 index 0000000000..40d7f82b9a --- /dev/null +++ b/vendor/vello_common/src/filter_effects.rs @@ -0,0 +1,1089 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Filter effects API based on the W3C Filter Effects specification. +//! +//! This module provides a comprehensive filter system supporting both high-level +//! CSS filter functions and low-level SVG filter primitives. The API is designed +//! to follow the W3C Filter Effects Module Level 1 specification. +//! +//! See: +//! +//! ## Implementation Status +//! +//! ### ✅ Implemented +//! +//! **Filter Functions:** +//! - `Blur` - Gaussian blur effect +//! +//! **Filter Primitives (Single Use Only):** +//! - `Flood` - Solid color fill +//! - `GaussianBlur` - Gaussian blur filter +//! - `DropShadow` - Drop shadow effect (compound primitive) +//! - `Offset` - Translation/shift (single primitive) +//! +//! **Note:** Currently only single primitive filters are supported. Filter graphs with +//! multiple connected primitives are not yet implemented. +//! +//! ### 🚧 Not Yet Implemented +//! +//! **Core Features:** +//! - `FilterGraph` execution - Chaining multiple filter primitives together +//! - `FilterInputs` - Connecting primitives to create complex effects +//! +//! **Filter Functions:** +//! - `Brightness`, `Contrast`, `Grayscale`, `HueRotate`, `Invert`, +//! `Opacity`, `Saturate`, `Sepia` +//! +//! **Filter Primitives:** +//! - `ColorMatrix` - Matrix-based color transformation +//! - `Composite` - Porter-Duff compositing operations +//! - `Blend` - Blend mode operations +//! - `Morphology` - Dilate/erode operations +//! - `ConvolveMatrix` - Custom convolution kernels +//! - `Turbulence` - Perlin noise generation +//! - `DisplacementMap` - Pixel displacement +//! - `ComponentTransfer` - Per-channel transfer functions +//! - `Image` - External image reference +//! - `Tile` - Tiling operation +//! - `DiffuseLighting`, `SpecularLighting` - Lighting effects + +use crate::color::{AlphaColor, Srgb}; +use crate::kurbo::{Affine, Rect}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use smallvec::SmallVec; + +/// The main filter system. +/// +/// A filter combines a graph of filter primitives with optional spatial bounds. +/// If bounds are specified, the filter only applies within that region. +#[derive(Debug, Clone, PartialEq)] +pub struct Filter { + /// Filter graph defining the effect pipeline. + pub graph: Arc, + // TODO: Add bounds restricting where the filter applies. + // Optional bounds restricting where the filter applies. + // If `None`, the filter applies to the entire filtered element. + // pub bounds: Option, +} + +impl Filter { + /// Create a simple filter system from a filter function. + /// + /// Converts a high-level CSS-style filter function into a filter graph. + /// Use this for simple effects like blur, brightness, etc. + pub fn from_function(function: FilterFunction) -> Self { + // Convert function to primitive + let primitive = match function { + FilterFunction::Blur { radius } => FilterPrimitive::GaussianBlur { + std_deviation: radius, + edge_mode: EdgeMode::default(), + }, + _ => unimplemented!("Filter function {:?} not supported", function), + }; + + Self::from_primitive(primitive) + } + + /// Create a filter system from a filter primitive. + /// + /// Creates a simple filter graph with a single primitive. + /// Use this for direct access to low-level SVG filter operations. + pub fn from_primitive(primitive: FilterPrimitive) -> Self { + let mut graph = FilterGraph::new(); + let filter_id = graph.add(primitive, None); + graph.set_output(filter_id); + + Self { + graph: Arc::new(graph), + } + } + + /// Calculate the bounds expansion for this filter in pixel/device space. + /// + /// Returns a `Rect` representing how many extra pixels are needed around the + /// filtered region to correctly compute the filter effect. For example, a blur + /// filter needs to sample beyond the original bounds to avoid edge artifacts. + /// + /// The expansion accounts for the transform (rotation, scale, and shear) to compute + /// the correct axis-aligned bounding box expansion in device space. + /// + /// The returned rect is centered at origin: + /// - x0: negative left expansion (in pixels) + /// - y0: negative top expansion (in pixels) + /// - x1: positive right expansion (in pixels) + /// - y1: positive bottom expansion (in pixels) + /// + /// # Arguments + /// * `transform` - The transform applied to this filter layer + pub fn bounds_expansion(&self, transform: &Affine) -> Rect { + let [a, b, c, d, _e, _f] = transform.as_coeffs(); + let linear_only = Affine::new([a, b, c, d, 0.0, 0.0]); + + self.graph.bounds_expansion(&linear_only) + } +} + +/// A directed acyclic graph (DAG) of filter operations. +/// +/// The graph represents a pipeline of filter primitives where outputs of some +/// primitives can be used as inputs to others. Each primitive has a unique `FilterId`. +#[derive(Debug, Clone, PartialEq)] +pub struct FilterGraph { + /// All filter primitives in the graph, stored in insertion order. + pub primitives: SmallVec<[FilterPrimitive; 1]>, + /// The final output filter ID whose result is the output of this graph. + pub output: FilterId, + /// Next available filter ID (monotonically increasing counter). + next_id: u16, + /// Accumulated bounds expansion from all primitives in the graph, cached in user space. + /// This is the axis-aligned bounding box of the expansion region (centered at origin), + /// which can be transformed to device space when needed. + expansion_rect: Rect, +} + +impl Default for FilterGraph { + fn default() -> Self { + Self::new() + } +} + +impl FilterGraph { + /// Create a new empty filter graph. + pub fn new() -> Self { + Self { + primitives: SmallVec::new(), + output: FilterId(0), + next_id: 0, + expansion_rect: Rect::ZERO, + } + } + + /// Add a filter primitive with optional inputs. + /// + /// Returns a `FilterId` that can be referenced by other primitives. + /// Automatically updates the accumulated bounds expansion based on the primitive's requirements. + pub fn add(&mut self, primitive: FilterPrimitive, _inputs: Option) -> FilterId { + let id = FilterId(self.next_id); + self.next_id += 1; + + // Update accumulated expansion by taking the union of rects + let primitive_rect = primitive.expansion_rect(); + self.expansion_rect = self.expansion_rect.union(primitive_rect); + + self.primitives.push(primitive); + + id + } + + /// Set the output filter for the graph. + pub fn set_output(&mut self, output: FilterId) { + self.output = output; + } + + /// Get the accumulated bounds expansion for all primitives in this graph. + /// + /// This returns the expansion required by all primitives in the graph, + /// representing the padding needed to render all filter effects correctly. + /// + /// The expansion accounts for the transform (rotation, scale, and shear) to compute + /// the correct axis-aligned bounding box expansion in device space. + /// + /// # Arguments + /// * `transform` - The transform applied to this filter layer + pub fn bounds_expansion(&self, transform: &Affine) -> Rect { + // Transform the cached expansion rect to device space + // transform_rect_bbox computes the axis-aligned bounding box of the transformed rect + transform.transform_rect_bbox(self.expansion_rect) + } +} + +/// All possible filter effects. +/// +/// This enum allows choosing between high-level filter functions (simple CSS-style effects) +/// and low-level filter primitives (complex SVG-style effects with full control). +/// Use `FilterFunction` for common effects like blur, and `FilterPrimitive` for +/// advanced composition and custom filter graphs. +#[derive(Debug, Clone)] +pub enum FilterEffect { + /// Simple, high-level filter functions. + Function(FilterFunction), + /// Low-level filter primitives (granular control). + Primitive(FilterPrimitive), +} + +/// High-level filter functions for common effects (CSS filter functions). +/// +/// These match the CSS Filter Effects specification and provide simple, +/// commonly-used visual effects without needing to construct a filter graph. +/// +/// See: +#[derive(Debug, Clone)] +pub enum FilterFunction { + /// Gaussian blur effect. + /// + /// Applies a Gaussian blur to the input image. Larger radius values + /// produce more blur. The blur is applied equally in all directions. + /// + /// Note: Per the W3C Filter Effects specification, this `radius` parameter + /// represents the standard deviation (σ) of the Gaussian function, not the + /// effective blur range. The effective blur range is approximately 3× this value. + Blur { + /// Standard deviation of the Gaussian blur in pixels. Must be non-negative. + /// A value of 0 means no blur. + /// + /// Despite being called "radius" (to match CSS filter syntax), this is + /// actually the standard deviation. The visible blur effect extends + /// approximately 3 times this value in each direction. + radius: f32, + }, + // + // ============================================================ + // TODO: The following filter functions are not yet implemented + // ============================================================ + // + /// Brightness adjustment. + /// + /// Adjusts the brightness of the input image using a linear multiplier. + Brightness { + /// Brightness amount: 0.0 = completely black, 1.0 = no change, >1.0 = brighter. + /// Must be non-negative. + amount: f32, + }, + /// Contrast adjustment. + /// + /// Adjusts the contrast of the input image. + Contrast { + /// Contrast amount: 0.0 = uniform gray, 1.0 = no change, >1.0 = higher contrast. + /// Must be non-negative. + amount: f32, + }, + /// Grayscale conversion. + /// + /// Converts the input to grayscale. Amount controls the strength of the conversion. + Grayscale { + /// Grayscale amount: 0.0 = original colors, 1.0 = full grayscale. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, + /// Hue rotation. + /// + /// Rotates the hue of all colors in the input image by the specified angle. + HueRotate { + /// Rotation angle in degrees. Can be negative. + /// 0° = no change, 180° = opposite hue, 360° = back to original. + angle: f32, + }, + /// Color inversion. + /// + /// Inverts the colors of the input image. + Invert { + /// Inversion amount: 0.0 = original colors, 1.0 = fully inverted. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, + /// Opacity adjustment. + /// + /// Multiplies the alpha channel by the specified amount. + Opacity { + /// Opacity amount: 0.0 = fully transparent, 1.0 = no change. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, + /// Saturation adjustment. + /// + /// Adjusts the color saturation of the input image. + Saturate { + /// Saturation amount: 0.0 = completely desaturated (grayscale), + /// 1.0 = no change, >1.0 = oversaturated. + /// Must be non-negative. + amount: f32, + }, + /// Sepia tone effect. + /// + /// Applies a sepia tone effect (vintage/old photo appearance). + Sepia { + /// Sepia amount: 0.0 = original colors, 1.0 = full sepia tone. + /// Values should be in range [0.0, 1.0]. + amount: f32, + }, +} + +/// Edge mode for filter operations. +/// +/// Determines how to extend the input image when filter operations require sampling +/// beyond the original image boundaries. This is particularly important for blur and +/// convolution operations near edges. +/// +/// See: +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum EdgeMode { + /// Extend by duplicating edge pixels (clamp to edge). + /// + /// The input image is extended along each border by replicating the color values + /// at the given edge of the input image. This prevents dark halos around edges. + Duplicate, + /// Extend by wrapping to the opposite edge (repeat/tile). + /// + /// The input image is extended by taking color values from the opposite edge, + /// creating a tiling effect. + Wrap, + /// Extend by mirroring across the edge. + /// + /// The input image is extended by taking color values mirrored across the edge. + /// This creates seamless continuation at boundaries. + Mirror, + /// Extend with transparent black (zeros). + /// + /// The input image is extended with pixel values of zero for R, G, B and A. + /// This is the default and most common mode, creating natural fade-to-transparent edges. + #[default] + None, +} + +/// Low-level filter primitives for granular control (SVG filter primitives). +/// +/// These are the building blocks for complex filter effects, corresponding to SVG +/// filter primitives. They can be combined in a `FilterGraph` to create sophisticated +/// visual effects. +/// +/// See: +#[derive(Debug, Clone, PartialEq)] +pub enum FilterPrimitive { + /// Generate a solid color fill. + /// + /// Creates a rectangle filled with the specified color, typically used as + /// input to other filter operations (e.g., for colored shadows). + Flood { + /// Fill color with alpha channel. + color: AlphaColor, + }, + /// Gaussian blur filter. + /// + /// Applies a Gaussian blur using the specified standard deviation (σ). + /// The effective blur range (distance over which pixels are sampled) is + /// approximately 3 × `std_deviation`, as this captures ~99.7% of the + /// Gaussian distribution. + GaussianBlur { + /// Standard deviation for the blur kernel. Larger values create more blur. + /// Must be non-negative. A value of 0 means no blur. + /// + /// This directly corresponds to the σ (sigma) parameter in the Gaussian + /// function. The visible blur effect extends approximately 3σ in each direction. + /// + /// TODO: Per the W3C specification, this should support separate x and y values. + /// The spec allows `stdDeviation` to be either one number (applied to both axes) + /// or two numbers (first for x-axis, second for y-axis). Currently only uniform + /// blur is supported. Consider changing to `(f32, f32)` or a dedicated type. + std_deviation: f32, + /// Edge mode determining how pixels beyond the input bounds are handled. + edge_mode: EdgeMode, + }, + /// Drop shadow effect (compound primitive). + /// + /// Creates a drop shadow by blurring the input's alpha channel, offsetting it, + /// and compositing it with the original. This is a compound operation that + /// combines multiple primitive operations into one. + /// + /// See: + DropShadow { + /// Horizontal offset of the shadow in pixels. Positive values shift right. + dx: f32, + /// Vertical offset of the shadow in pixels. Positive values shift down. + dy: f32, + /// Blur standard deviation for the shadow. Larger values create softer shadows. + std_deviation: f32, + /// Shadow color with alpha channel. Alpha controls shadow opacity. + color: AlphaColor, + /// Edge mode for handling boundaries during blur operation. + /// Default is `EdgeMode::None` per SVG spec. + edge_mode: EdgeMode, + }, + // + // ============================================================ + // TODO: The following filter primitives are not yet implemented + // ============================================================ + // + /// Matrix-based color transformation. + /// + /// Applies a 4x5 matrix transformation to colors, allowing arbitrary + /// color space transformations, hue shifts, and color adjustments. + ColorMatrix { + /// 4x5 color transformation matrix: 4 rows (R,G,B,A) × 5 columns (R,G,B,A,offset). + /// Each output channel is computed as a linear combination of input channels plus offset. + matrix: [f32; 20], + }, + /// Geometric offset/translation. + /// + /// Shifts the input image by the specified offset. Useful for creating + /// shadow effects or positioning elements in a filter graph. + Offset { + /// Horizontal offset in pixels. Positive values shift right. + dx: f32, + /// Vertical offset in pixels. Positive values shift down. + dy: f32, + }, + + /// Composite two inputs using Porter-Duff compositing operations. + /// + /// Combines two input images using standard compositing operators + /// (over, in, out, atop, xor) or custom arithmetic combination. + Composite { + /// Porter-Duff compositing operator to apply. + operator: CompositeOperator, + }, + /// Blend two inputs using blend modes. + /// + /// Combines two input images using Photoshop-style blend modes + /// (multiply, screen, overlay, etc.). + Blend { + /// Blend mode determining how colors are combined. + mode: BlendMode, + }, + /// Morphological operations (dilate/erode). + /// + /// Expands (dilate) or contracts (erode) the shapes in the input image. + /// Useful for creating outline effects or cleaning up edges. + Morphology { + /// Morphological operator determining whether to erode or dilate. + operator: MorphologyOperator, + /// Operation radius in pixels. Larger values create stronger effects. + radius: f32, + }, + /// Custom convolution kernel for image processing. + /// + /// Applies a custom convolution matrix to the input image, enabling + /// effects like sharpening, edge detection, embossing, and custom filters. + ConvolveMatrix { + /// Convolution kernel specification including size, values, and normalization. + kernel: ConvolutionKernel, + }, + /// Generate Perlin noise/turbulence patterns. + /// + /// Creates procedural noise patterns useful for textures, clouds, + /// marble effects, and other organic-looking randomness. + Turbulence { + /// Base frequency for noise generation. Higher values create finer detail. + base_frequency: f32, + /// Number of octaves for fractal noise. More octaves add finer detail. + num_octaves: u32, + /// Random seed for reproducible noise generation. + seed: u32, + /// Type of noise: smooth fractal or more chaotic turbulence. + turbulence_type: TurbulenceType, + }, + /// Displace pixels using a displacement map. + /// + /// Uses the color values from a second input to spatially displace pixels + /// in the primary input, creating warping and distortion effects. + DisplacementMap { + /// Scale factor controlling the displacement intensity. + scale: f32, + /// Color channel from the displacement map used for X-axis displacement. + x_channel: ColorChannel, + /// Color channel from the displacement map used for Y-axis displacement. + y_channel: ColorChannel, + }, + /// Per-channel component transfer using lookup tables or functions. + /// + /// Applies independent transfer functions to each color channel, + /// enabling color corrections, gamma adjustments, and custom mappings. + ComponentTransfer { + /// Transfer function applied to the red channel (None = identity). + red_function: Option, + /// Transfer function applied to the green channel (None = identity). + green_function: Option, + /// Transfer function applied to the blue channel (None = identity). + blue_function: Option, + /// Transfer function applied to the alpha channel (None = identity). + alpha_function: Option, + }, + /// Reference an external image as filter input. + /// + /// Allows using pre-existing images (from an atlas or resource) as + /// input to filter operations, useful for texturing and overlays. + Image { + /// Identifier referencing an image in the resource atlas. + image_id: u32, + /// Optional 2D affine transformation matrix [a, b, c, d, e, f]. + /// Transforms the image before using it as filter input. + transform: Option<[f32; 6]>, + }, + /// Tile the input to fill the filter region. + /// + /// Repeats the input image to fill the entire filter primitive subregion, + /// creating a tiling/repeating pattern. + Tile, + /// Diffuse lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating diffuse (matte) reflection from a light source. + DiffuseLighting { + /// Surface scale factor for converting alpha values to heights. + surface_scale: f32, + /// Diffuse reflection constant (kd). Controls lighting intensity. + diffuse_constant: f32, + /// Kernel unit length for gradient calculations in user space. + kernel_unit_length: f32, + /// Configuration of the light source (point, distant, or spot). + light_source: LightSource, + }, + /// Specular lighting simulation. + /// + /// Creates a lighting effect by treating the input's alpha channel as a height map + /// and calculating specular (shiny) reflection highlights from a light source. + SpecularLighting { + /// Surface scale factor for converting alpha values to heights. + surface_scale: f32, + /// Specular reflection constant (ks). Controls highlight intensity. + specular_constant: f32, + /// Specular reflection exponent. Controls highlight sharpness (higher = sharper). + specular_exponent: f32, + /// Kernel unit length for gradient calculations in user space. + kernel_unit_length: f32, + /// Configuration of the light source (point, distant, or spot). + light_source: LightSource, + }, +} + +impl FilterPrimitive { + /// Calculate the bounds expansion as a `Rect` in user space. + /// + /// Returns a rectangle centered at the origin representing how much the filter + /// expands the processing region in each direction. The rect coordinates are: + /// - x0: negative left expansion + /// - y0: negative top expansion + /// - x1: positive right expansion + /// - y1: positive bottom expansion + /// + /// A `Rect::ZERO` means no expansion. This representation allows the expansion + /// to be correctly transformed (including rotation) using standard rect transforms. + /// + /// For example, a blur filter needs additional pixels around the edges (3*sigma). + /// Most filters that don't sample neighboring pixels return `Rect::ZERO`. + pub fn expansion_rect(&self) -> Rect { + match self { + Self::GaussianBlur { std_deviation, .. } => { + // Gaussian blur expands uniformly by 3*sigma (covers 99.7% of distribution) + let radius = (*std_deviation * 3.0) as f64; + Rect::new(-radius, -radius, radius, radius) + } + Self::Offset { dx, dy } => { + // Offset shifts pixels; expand bounds asymmetrically so shifted content isn't cut. + let dx = *dx as f64; + let dy = *dy as f64; + Rect::new(dx.min(0.0), dy.min(0.0), dx.max(0.0), dy.max(0.0)) + } + Self::DropShadow { + std_deviation, + dx, + dy, + .. + } => { + // Drop shadow = blur + offset + composite with original + // The expansion rect encompasses both the blur and the offset + let blur_radius = (*std_deviation * 3.0) as f64; + let dx = *dx as f64; + let dy = *dy as f64; + + Rect::new( + -(blur_radius + (-dx).max(0.0)), + -(blur_radius + (-dy).max(0.0)), + blur_radius + dx.max(0.0), + blur_radius + dy.max(0.0), + ) + } + // Most other filters don't expand bounds + _ => Rect::ZERO, + } + } +} + +#[cfg(test)] +mod offset_expansion_tests { + use super::FilterPrimitive; + use crate::kurbo::Rect; + + #[test] + fn offset_expands_in_direction_of_shift() { + let p = FilterPrimitive::Offset { dx: 2.5, dy: -3.0 }; + assert_eq!( + p.expansion_rect(), + Rect::new(0.0, -3.0, 2.5, 0.0), + "Offset expansion should be asymmetric and include the shift vector" + ); + } +} + +/// Unique identifier for a filter primitive in the graph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct FilterId(pub u16); + +/// Input connections for a filter primitive. +#[derive(Debug, Clone, PartialEq)] +pub struct FilterInputs { + /// Primary input ("in" attribute in SVG). + pub primary: FilterInput, + /// Secondary input ("in2" attribute in SVG, for composite/blend operations). + pub secondary: Option, +} + +impl FilterInputs { + /// Create filter inputs with a single input. + /// + /// Use this for primitives that operate on a single source (blur, color matrix, etc.). + pub fn single(input: FilterInput) -> Self { + Self { + primary: input, + secondary: None, + } + } + + /// Create filter inputs with two inputs (for composite, blend, etc.). + /// + /// Use this for primitives that combine two sources (composite, blend, displacement map, etc.). + pub fn dual(input1: FilterInput, input2: FilterInput) -> Self { + Self { + primary: input1, + secondary: Some(input2), + } + } +} + +/// A single filter input. +#[derive(Debug, Clone, PartialEq)] +pub enum FilterInput { + /// Input from a source (`SourceGraphic`, `SourceAlpha`, etc.). + Source(FilterSource), + /// Input from another filter's result. + Result(FilterId), +} + +/// Filter input sources. +/// +/// Defines the various built-in sources that can be used as filter inputs, +/// matching the SVG filter primitive input types. These represent implicit +/// inputs available to any filter primitive without requiring previous operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterSource { + /// The original graphic content being filtered. + /// + /// This is the default input - the rendered result of the element + /// the filter is applied to, including all its fill, stroke, and content. + SourceGraphic, + /// Alpha channel only of the original graphic. + /// + /// Useful for creating effects based on shape/transparency, such as + /// shadows that follow the element's outline. + SourceAlpha, + /// Background image content behind the filtered element. + /// + /// Allows filters to incorporate or blend with content behind the element. + /// Not always available depending on the rendering context. + BackgroundImage, + /// Alpha channel only of the background image. + /// + /// The transparency mask of the background content. + BackgroundAlpha, + /// The fill paint of the element as an image input. + /// + /// For elements with gradient or pattern fills, this provides access + /// to the fill as a filter input. + FillPaint, + /// The stroke paint of the element as an image input. + /// + /// For elements with gradient or pattern strokes, this provides access + /// to the stroke as a filter input. + StrokePaint, +} + +/// Pre-built compound effects for common use cases. +/// +/// These effects combine multiple filter primitives into commonly-used visual effects. +/// They provide a convenient high-level API for complex multi-step filter operations. +/// +/// **Note:** These are planned but not yet implemented. Use `FilterGraph` to manually +/// construct these effects from primitives. +#[derive(Debug, Clone)] +pub enum CompoundFilter { + /// Inner shadow effect (shadow inside the shape). + /// + /// Creates a shadow that appears inside the boundaries of the shape, + /// giving a recessed or inset appearance. This is the opposite of a drop shadow. + InnerShadow { + /// Horizontal offset of the shadow in pixels. Positive values shift right. + dx: f32, + /// Vertical offset of the shadow in pixels. Positive values shift down. + dy: f32, + /// Blur radius for the shadow in pixels. Larger values create softer shadows. + blur: f32, + /// Shadow color with alpha channel. + color: AlphaColor, + }, + /// Glow effect around the shape. + /// + /// Creates a soft glowing halo around the shape by blurring and + /// compositing a colored version with the original. + Glow { + /// Blur radius for the glow in pixels. Larger values create softer glows. + blur: f32, + /// Glow color with alpha channel. + color: AlphaColor, + }, + /// Bevel effect (3D raised/recessed appearance). + /// + /// Creates a 3D beveled edge effect using lighting simulation, + /// making the shape appear raised or recessed from the surface. + Bevel { + /// Light source angle in degrees (0° = right, 90° = up). + angle: f32, + /// Width of the bevel edge in pixels. + distance: f32, + /// Color for the highlight (lit) side of the bevel. + highlight: AlphaColor, + /// Color for the shadow (dark) side of the bevel. + shadow: AlphaColor, + }, + /// Emboss effect for a raised relief appearance. + /// + /// Creates an embossed/stamped appearance by simulating lighting + /// on a raised surface based on the shape's alpha channel. + Emboss { + /// Light angle in degrees determining emboss direction. + angle: f32, + /// Depth of the emboss effect. + depth: f32, + /// Overall strength/intensity of the effect (0.0 = none, 1.0 = full). + amount: f32, + }, +} + +/// Composite operators for combining filter inputs. +/// +/// These are the Porter-Duff compositing operators used to combine two images. +/// Each operator defines how the source (input 1) and destination (input 2) +/// are combined based on their color and alpha values. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CompositeOperator { + /// Source over destination (standard alpha blending). + /// + /// The source is composited over the destination. This is the most common + /// blending mode where source alpha determines visibility. + Over, + /// Source in destination (intersection). + /// + /// The source is only visible where the destination is opaque. + /// Result alpha = `source_alpha` × `dest_alpha`. + In, + /// Source out destination (subtract). + /// + /// The source is only visible where the destination is transparent. + /// Useful for masking/cutting out regions. + Out, + /// Source atop destination. + /// + /// Source is composited over destination, but only where destination is opaque. + Atop, + /// Source XOR destination (exclusive or). + /// + /// Shows source where destination is transparent and vice versa, + /// but not where both are opaque. + Xor, + /// Arithmetic combination with custom coefficients. + /// + /// Custom linear combination: result = k1*src*dst + k2*src + k3*dst + k4. + /// Allows creating custom compositing operations beyond the standard Porter-Duff set. + Arithmetic { + /// Coefficient k1 for the (source * destination) term. + k1: f32, + /// Coefficient k2 for the source term. + k2: f32, + /// Coefficient k3 for the destination term. + k3: f32, + /// Constant offset k4 added to the result. + k4: f32, + }, +} + +/// Blend modes for combining colors. +/// +/// These are blend modes that define how to combine the colors +/// of two layers. Unlike compositing operators which deal with alpha, blend modes +/// focus on color mixing while preserving the compositing behavior. +/// +/// See: +pub type BlendMode = peniko::Mix; + +/// Morphological operators for dilate/erode operations. +/// +/// These operators modify the shape of objects by expanding or contracting them. +/// They work by examining neighborhoods of pixels and applying min/max operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MorphologyOperator { + /// Erode operation (shrink/thin shapes). + /// + /// Makes objects smaller by removing pixels at the edges. Takes the minimum + /// value in the neighborhood. Useful for removing noise or separating touching objects. + Erode, + /// Dilate operation (expand/thicken shapes). + /// + /// Makes objects larger by adding pixels at the edges. Takes the maximum + /// value in the neighborhood. Useful for filling holes or connecting nearby objects. + Dilate, +} + +/// Convolution kernel for custom filtering operations. +/// +/// Defines a square matrix of weights used for convolution-based image processing. +/// The kernel is applied to each pixel by multiplying surrounding pixels by the weights, +/// summing the results, dividing by the divisor, and adding the bias. +#[derive(Debug, Clone, PartialEq)] +pub struct ConvolutionKernel { + /// Kernel size (e.g., 3 for a 3×3 kernel, 5 for 5×5). + /// The kernel must be square, so this defines both width and height. + pub size: u32, + /// Kernel weight values in row-major order. + /// Length must equal size × size. Center of kernel is typically at (size/2, size/2). + pub values: Vec, + /// Normalization divisor applied to the convolution result. + /// Common practice is to use the sum of all weights for averaging, or 1.0 otherwise. + pub divisor: f32, + /// Bias value added to the result after normalization. + /// Useful for edge detection or emboss effects to shift the result range. + pub bias: f32, + /// Whether to preserve the alpha channel unchanged. + /// If true, convolution only applies to RGB; if false, it applies to RGBA. + pub preserve_alpha: bool, +} + +/// Types of turbulence noise generation. +/// +/// Determines the algorithm used for generating procedural noise patterns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurbulenceType { + /// Fractal noise (smooth, natural-looking Perlin noise). + /// + /// Creates smooth, continuous patterns suitable for natural textures + /// like clouds, marble, wood grain, or terrain. + FractalNoise, + /// Turbulence noise (more chaotic and energetic). + /// + /// Creates more chaotic patterns with sharper transitions, + /// suitable for fire, smoke, or turbulent effects. + Turbulence, +} + +/// Color channels for displacement mapping and channel selection. +/// +/// Specifies which color channel to use for operations that need to +/// extract or reference individual channels from an image. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorChannel { + /// Red color channel (R component). + Red, + /// Green color channel (G component). + Green, + /// Blue color channel (B component). + Blue, + /// Alpha channel (transparency/opacity). + Alpha, +} + +/// Transfer functions for component transfer operations. +/// +/// These functions map input color channel values to output values, +/// enabling gamma correction, color grading, and custom color curves. +/// Input and output values are typically in the range [0, 1]. +#[derive(Debug, Clone, PartialEq)] +pub enum TransferFunction { + /// Identity function (output = input, no change). + Identity, + /// Table lookup with linear interpolation. + /// + /// Maps input values using a lookup table with linear interpolation between entries. + /// Input 0.0 maps to values\[0\], 1.0 maps to values\[n-1\], intermediate values interpolate. + Table { + /// Lookup table values defining the transfer curve. + /// More values provide smoother curves. Minimum 2 values required. + values: Vec, + }, + /// Discrete step function (posterization). + /// + /// Maps input to discrete output values without interpolation, creating step/banding effects. + /// Each segment gets a constant output value from the table. + Discrete { + /// Step values for each discrete output level. + /// Input range is divided into len(values) segments, each mapping to one value. + values: Vec, + }, + /// Linear function: output = slope × input + intercept. + /// + /// Simple linear transformation of the input value. + Linear { + /// Slope coefficient (rate of change). + slope: f32, + /// Intercept offset (constant added to result). + intercept: f32, + }, + /// Gamma correction: output = amplitude × input^exponent + offset. + /// + /// Applies power-law transformation, commonly used for gamma correction and + /// adjusting midtone brightness without affecting blacks or whites. + Gamma { + /// Amplitude multiplier applied to the result. + amplitude: f32, + /// Gamma exponent (< 1 brightens, > 1 darkens midtones). + exponent: f32, + /// Offset added to the final result. + offset: f32, + }, +} + +/// Light source configurations for lighting effects. +/// +/// Defines different types of light sources used in diffuse and specular lighting +/// filter primitives. Each type has different characteristics and use cases. +#[derive(Debug, Clone, PartialEq)] +pub enum LightSource { + /// Distant light source (infinitely far away, like the sun). + /// + /// All rays are parallel, creating uniform lighting across the surface. + /// Direction is specified using spherical coordinates (azimuth and elevation). + Distant { + /// Azimuth angle in degrees (0° = pointing right, 90° = pointing up). + /// Defines the horizontal direction of the light. + azimuth: f32, + /// Elevation angle in degrees (0° = horizon, 90° = directly overhead). + /// Defines the vertical angle of the light source. + elevation: f32, + }, + /// Point light source at a specific 3D position. + /// + /// Light radiates uniformly in all directions from a single point. + /// Intensity decreases with distance. Like a light bulb. + Point { + /// Light source X coordinate in user space. + x: f32, + /// Light source Y coordinate in user space. + y: f32, + /// Light source Z coordinate (height above the surface). + /// Larger values create softer lighting across larger areas. + z: f32, + }, + /// Spot light with position, direction, and cone angle. + /// + /// Light emanates from a point in a specific direction with limited spread. + /// Like a flashlight or stage spotlight with adjustable focus. + Spot { + /// Light source X coordinate in user space. + x: f32, + /// Light source Y coordinate in user space. + y: f32, + /// Light source Z coordinate (height above the surface). + z: f32, + /// X coordinate the spotlight is aimed at. + points_at_x: f32, + /// Y coordinate the spotlight is aimed at. + points_at_y: f32, + /// Z coordinate the spotlight is aimed at. + points_at_z: f32, + /// Specular exponent controlling the focus/sharpness of the spotlight beam. + /// Higher values create tighter, more focused beams. + specular_exponent: f32, + /// Optional cone angle in degrees limiting the spotlight spread. + /// If None, the light spreads based only on the specular exponent. + limiting_cone_angle: Option, + }, +} + +/// Common color transformation matrices. +/// +/// These 4x5 matrices are used with the `ColorMatrix` filter primitive. +/// Each row transforms a color channel: [R, G, B, A, offset]. +pub mod matrices { + /// Identity matrix (no change). + pub const IDENTITY: [f32; 20] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, // Red + 0.0, 1.0, 0.0, 0.0, 0.0, // Green + 0.0, 0.0, 1.0, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ]; + + /// Extract alpha channel to RGB (for shadow effects). + pub const ALPHA_TO_BLACK: [f32; 20] = [ + 0.0, 0.0, 0.0, 1.0, 0.0, // Red = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Green = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Blue = Alpha + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha = Alpha + ]; + + /// Grayscale conversion matrix using luminosity weights. + pub const GRAYSCALE: [f32; 20] = [ + 0.2126, 0.7152, 0.0722, 0.0, 0.0, // Red + 0.2126, 0.7152, 0.0722, 0.0, 0.0, // Green + 0.2126, 0.7152, 0.0722, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ]; + + /// Sepia tone matrix for vintage photo effect. + pub const SEPIA: [f32; 20] = [ + 0.393, 0.769, 0.189, 0.0, 0.0, // Red + 0.349, 0.686, 0.168, 0.0, 0.0, // Green + 0.272, 0.534, 0.131, 0.0, 0.0, // Blue + 0.0, 0.0, 0.0, 1.0, 0.0, // Alpha + ]; +} + +/// Common convolution kernels. +/// +/// These kernels are used with the `ConvolveMatrix` filter primitive +/// for various image processing effects. All provided kernels are 3x3. +pub mod kernels { + use super::ConvolutionKernel; + use alloc::vec; + + /// 3x3 Gaussian blur kernel for basic smoothing. + pub fn gaussian_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![1.0, 2.0, 1.0, 2.0, 4.0, 2.0, 1.0, 2.0, 1.0], + divisor: 16.0, + bias: 0.0, + preserve_alpha: false, + } + } + + /// 3x3 Sharpen kernel to enhance edges and details. + pub fn sharpen_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![0.0, -1.0, 0.0, -1.0, 5.0, -1.0, 0.0, -1.0, 0.0], + divisor: 1.0, + bias: 0.0, + preserve_alpha: true, + } + } + + /// 3x3 Edge detection kernel (Laplacian operator). + pub fn edge_detect_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![-1.0, -1.0, -1.0, -1.0, 8.0, -1.0, -1.0, -1.0, -1.0], + divisor: 1.0, + bias: 0.0, + preserve_alpha: true, + } + } + + /// 3x3 Emboss kernel for creating a raised/beveled appearance. + pub fn emboss_3x3() -> ConvolutionKernel { + ConvolutionKernel { + size: 3, + values: vec![-2.0, -1.0, 0.0, -1.0, 1.0, 1.0, 0.0, 1.0, 2.0], + divisor: 1.0, + bias: 0.5, + preserve_alpha: true, + } + } +} diff --git a/vendor/vello_common/src/flatten.rs b/vendor/vello_common/src/flatten.rs new file mode 100644 index 0000000000..be219c6c32 --- /dev/null +++ b/vendor/vello_common/src/flatten.rs @@ -0,0 +1,286 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Flattening filled and stroked paths. + +use crate::flatten_simd::{Callback, LinePathEl}; +use crate::geometry::RectU16; +use crate::kurbo::{self, Affine, PathEl, Stroke, StrokeCtx, StrokeOpts}; +use alloc::vec::Vec; +use fearless_simd::{Level, Simd, dispatch}; +use log::warn; + +pub use crate::flatten_simd::FlattenCtx; + +// The current tolerance is set to 0.25. Since `sqrt` doesn't work in const contexts, we instead +// hardcode the squared tolerance and derive the others from that. +pub(crate) const SQRT_TOL: f64 = 0.5; +pub(crate) const TOL: f64 = SQRT_TOL * SQRT_TOL; +pub(crate) const TOL_2: f64 = TOL * TOL; + +/// A point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Point { + /// The x coordinate of the point. + pub x: f32, + /// The y coordinate of the point. + pub y: f32, +} + +impl Point { + /// The point `(0, 0)`. + pub const ZERO: Self = Self::new(0., 0.); + + /// Create a new point. + pub const fn new(x: f32, y: f32) -> Self { + Self { x, y } + } +} + +impl From for Point { + #[inline(always)] + fn from(value: kurbo::Point) -> Self { + Self { + x: value.x as f32, + y: value.y as f32, + } + } +} + +impl core::ops::Add for Point { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + Self::new(self.x + rhs.x, self.y + rhs.y) + } +} + +impl core::ops::Sub for Point { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + Self::new(self.x - rhs.x, self.y - rhs.y) + } +} + +impl core::ops::Mul for Point { + type Output = Self; + + fn mul(self, rhs: f32) -> Self { + Self::new(self.x * rhs, self.y * rhs) + } +} + +/// A line. +#[derive(Clone, Copy, Debug)] +pub struct Line { + /// The start point of the line. + pub p0: Point, + /// The end point of the line. + pub p1: Point, +} + +impl Line { + /// Create a new line. + pub fn new(p0: Point, p1: Point) -> Self { + Self { p0, p1 } + } +} + +/// Flatten a filled Bézier path into line segments. +/// +/// # Open subpaths and culling +/// +/// Open subpaths in the input path get closed by connecting the last endpoint in the subpath to +/// the starting point. The output lines in `line_buf` describe the flattened path, but these lines +/// may describe open subpaths, as some path elements may have been culled. +/// +/// For example, consider the following, where the box describes the viewport, a path is marked by +/// `*`, and the region to be filled in the viewport is shaded. For ease of drawing the ASCII art, +/// the path elements are all lines ([`PathEl::LineTo`]), but the same also holds for Bézier path +/// elements. +/// +/// ```text +/// ---> winding scan direction +/// +/// * * * * * +/// * * +/// ---------- * ----- * +/// | *░░░░░░░| * +/// | *░░░░░░░░░| * +/// | *░░░░░░░░░░░| * +/// | *░░░░░░░░░░░░░| * +/// |*░░░░░░░░░░░░░░░| * +/// *|░░░░░░░░░░░░░░░░| * +/// * |░░░░░░░░░░░░░░░░| * +/// * |░░░░░░░░░░░░░░░░| * +/// * ------------------ * +/// * * +/// * * +/// * * +/// * * +/// * * +/// * * +/// * * +/// * * * * * * * * * * * * * * +/// ``` +/// +/// Because the winding scan direction is from left to right, only the left-of-viewport and +/// diagonal lines matter in later stages of rendering for the winding number and pixel coverage. +/// The other three lines can be culled. +/// +/// ```text +/// * +/// * +/// ---------- * ----- +/// | *░░░░░░░| +/// | *░░░░░░░░░| +/// | *░░░░░░░░░░░| +/// | *░░░░░░░░░░░░░| +/// |*░░░░░░░░░░░░░░░| +/// *|░░░░░░░░░░░░░░░░| +/// * |░░░░░░░░░░░░░░░░| +/// * |░░░░░░░░░░░░░░░░| +/// * ------------------ +/// * +/// * +/// * +/// * +/// * +/// * +/// * +/// ``` +/// +/// It is important to keep these flattened subpaths open after culling, as closing the subpaths +/// might yield different geometry like the following. +/// +/// ```text +/// * +/// ** +/// ---------- * *---- +/// | *░░* | +/// | *░░░* | +/// | *░░░░* | +/// | *░░░░░* | +/// |*░░░░░░* | +/// *|░░░░░░* | +/// * |░░░░░* | +/// * |░░░░* | +/// * --- * ------------ +/// * * +/// * * +/// * * +/// * * +/// * * +/// ** +/// * +/// ``` +pub fn fill( + level: Level, + path: impl IntoIterator, + affine: Affine, + line_buf: &mut Vec, + ctx: &mut FlattenCtx, + cull_bbox: RectU16, +) { + dispatch!(level, simd => fill_impl(simd, path, affine, line_buf, ctx, cull_bbox)); +} + +/// Flatten a filled bezier path into line segments. +/// +/// See the note about open subpaths and culling on [`fill`]. +#[inline(always)] +pub fn fill_impl( + simd: S, + path: impl IntoIterator, + affine: Affine, + line_buf: &mut Vec, + flatten_ctx: &mut FlattenCtx, + cull_bbox: RectU16, +) { + line_buf.clear(); + let mut lb = FlattenerCallback { + line_buf, + start: Point::ZERO, + p0: Point::ZERO, + is_nan: false, + }; + + crate::flatten_simd::flatten(simd, path, affine, &mut lb, flatten_ctx, cull_bbox); + + // A path that contains NaN is ill-defined, so ignore it. + if lb.is_nan { + warn!("A path contains NaN, ignoring it."); + + line_buf.clear(); + } +} +/// Flatten a stroked Bézier path into line segments. +/// +/// See the note about open subpaths and culling on [`fill`]. +pub fn stroke( + level: Level, + path: impl IntoIterator, + style: &Stroke, + affine: Affine, + line_buf: &mut Vec, + flatten_ctx: &mut FlattenCtx, + stroke_ctx: &mut StrokeCtx, + cull_bbox: RectU16, +) { + // TODO: Temporary hack to ensure that strokes are scaled properly by the transform. + let tolerance = TOL + / affine.as_coeffs()[0] + .abs() + .max(affine.as_coeffs()[3].abs()) + .max(1.); + + expand_stroke(path, style, tolerance, stroke_ctx); + fill( + level, + stroke_ctx.output(), + affine, + line_buf, + flatten_ctx, + cull_bbox, + ); +} + +/// Expand a stroked path to a filled path. +pub fn expand_stroke( + path: impl IntoIterator, + style: &Stroke, + tolerance: f64, + stroke_ctx: &mut StrokeCtx, +) { + kurbo::stroke_with(path, style, &StrokeOpts::default(), tolerance, stroke_ctx); +} + +struct FlattenerCallback<'a> { + line_buf: &'a mut Vec, + start: Point, + p0: Point, + is_nan: bool, +} + +impl Callback for FlattenerCallback<'_> { + #[inline(always)] + fn callback(&mut self, el: LinePathEl) { + match el { + LinePathEl::MoveTo(p) => { + self.is_nan |= p.is_nan(); + + let p = p.into(); + self.start = p; + self.p0 = p; + } + LinePathEl::LineTo(p) => { + self.is_nan |= p.is_nan(); + + let p = p.into(); + self.line_buf.push(Line::new(self.p0, p)); + self.p0 = p; + } + } + } +} diff --git a/vendor/vello_common/src/flatten_simd.rs b/vendor/vello_common/src/flatten_simd.rs new file mode 100644 index 0000000000..e71df65ca3 --- /dev/null +++ b/vendor/vello_common/src/flatten_simd.rs @@ -0,0 +1,687 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! This is a temporary module that contains a SIMD version of flattening of cubic curves, as +//! well as some code that was copied from kurbo, which is needed to reimplement the +//! full `flatten` method. + +#[cfg(not(feature = "std"))] +use crate::kurbo::common::FloatFuncs as _; +use crate::kurbo::{CubicBez, Line, ParamCurve, ParamCurveNearest, PathEl, Point, QuadBez}; +use crate::{ + flatten::{SQRT_TOL, TOL, TOL_2}, + geometry::RectU16, + kurbo::Affine, + tile::Tile, +}; +use alloc::vec::Vec; +use bytemuck::{Pod, Zeroable}; +use fearless_simd::*; + +/// The element of a path made of lines. +/// +/// Each subpath must start with a `MoveTo`. Closing of subpaths is not supported, and subpaths are +/// not closed implicitly when a new subpath (with `MoveTo`) is started. It is expected that closed +/// subpaths are watertight in the sense that the last `LineTo` matches exactly with the first +/// `MoveTo`. +/// +/// This intentionally allows for non-watertight subpaths, as, e.g., lines that are fully outside +/// of the viewport do not need to be drawn. +/// +/// See [`PathEl`] for a more general-purpose path element type. +pub(crate) enum LinePathEl { + MoveTo(Point), + LineTo(Point), +} + +// Unlike kurbo, which takes a closure with a callback for outputting the lines, we use a trait +// instead. The reason is that this way the callback can be inlined, which is not possible with +// a closure and turned out to have a noticeable overhead. +pub(crate) trait Callback { + fn callback(&mut self, el: LinePathEl); +} + +/// See the docs for the kurbo implementation of flattening: +/// +/// +/// This version works using a similar approach but using f32x4/f32x8 SIMD instead. +#[inline(always)] +pub(crate) fn flatten( + simd: S, + path: impl IntoIterator, + // Note: we explicitly pass the `Affine` here instead of using `map` on + // the iterator because it seems like the `map` function isn't always inlined + // properly, even when annotating the closure with `#[inline(always)]`. + // See https://github.com/linebender/vello/pull/1600. + affine: Affine, + callback: &mut impl Callback, + flatten_ctx: &mut FlattenCtx, + cull_bbox: RectU16, +) { + flatten_ctx.flattened_cubics.clear(); + + // For the culling performed here to be correct, the top y coordinate of the cull bbox must be + // aligned to strip row boundaries. Consider the alternative: for example, a strip row starting + // at y=8, a cull bbox starting at y=10, and (nearly) horizontal geometry at y=9 that does not + // cross into the cull bbox and does not extend above y=8 (and note this is all in a y-down + // coordinate space). + // + // The culling performed here would remove that geometry. However, as it does not extend above + // the strip row, it does not add coarse winding to the strip, and does not produce a sparse + // fill. Yet, if this is the top part of, say, a rectangle that is partially visible, the + // geometry is necessary for tiling to emit intermediate tiles that are necessary for the + // bottom part of the strip to get filled. + // + // Therefore, we align `top` to strip row boundaries, such that all intermediate tiles are + // produced. + // + // This is not necessary for the bottom. Consider a path where a segment extends just below the + // cull bbox, but remains in the same strip row. The path is closed, so if anything is to be + // rendered at all, there will be other geometry above that edge of the cull bbox. If that + // geometry extends above the row, there will be coarse winding for a sparse fill. If not, it + // there will be geometry to generate the intermediate tiles. + let left = cull_bbox.x0 as f64; + let top = ((cull_bbox.y0 / Tile::HEIGHT) * Tile::HEIGHT) as f64; + let right = cull_bbox.x1 as f64; + let bottom = cull_bbox.y1 as f64; + + let mut path = path.into_iter(); + let Some(first_el) = path.next() else { + return; + }; + let first_el = affine * first_el; + let PathEl::MoveTo(start_pt) = first_el else { + debug_assert!( + matches!(first_el, PathEl::MoveTo(_)), + "Non-empty paths must begin with `PathEl::MoveTo`, got {first_el:?}" + ); + return; + }; + + let mut start_pt = start_pt; + let mut last_pt = start_pt; + callback.callback(LinePathEl::MoveTo(start_pt)); + + for el in path { + match affine * el { + PathEl::MoveTo(p) => { + if last_pt != start_pt { + callback.callback(LinePathEl::LineTo(start_pt)); + } + last_pt = p; + start_pt = p; + callback.callback(LinePathEl::MoveTo(p)); + } + PathEl::LineTo(p) => { + last_pt = p; + callback.callback(LinePathEl::LineTo(p)); + } + PathEl::QuadTo(p1, p2) => { + let p0 = last_pt; + let line = Line::new(p0, p2); + // If the quadratic Bézier is fully to the right, top, or bottom of the culling + // bbox, it does not impact pixel coverage or winding. We can ignore it. The + // following checks that conservatively by checking whether the bounding box of the + // Bézier's control points is fully outside the culling bbox. + if [p0, p1, p2].into_iter().all(|p| p.x > right) + || [p0, p1, p2].into_iter().all(|p| p.y < top) + || [p0, p1, p2].into_iter().all(|p| p.y > bottom) + { + callback.callback(LinePathEl::MoveTo(p2)); + } + // The following checks two things. First, if the quadratic Bézier is fully to the + // left of the culling bbox, it may affect pixel coverage and winding, but its + // exact shape does not matter. It can be emitted as a line segment [p0, p2]. + // + // Second, an upper bound on the shortest distance of any point on the quadratic + // Bézier curve to the line segment [p0, p2] is 1/2 of the control-point-to-line-segment + // distance. + // + // The derivation is similar to that for the cubic Bézier (see below). In + // short: + // + // q(t) = B0(t) p0 + B1(t) p1 + B2(t) p2 + // dist(q(t), [p0, p1]) <= B1(t) dist(p1, [p0, p1]) + // = 2 (1-t)t dist(p1, [p0, p1]). + // + // The maximum occurs at t=1/2, hence + // max(dist(q(t), [p0, p1] <= 1/2 dist(p1, [p0, p1])). + // + // The following takes the square to elide the square root of the Euclidean + // distance. + else if [p0, p1, p2].into_iter().all(|p| p.x < left) + || line.nearest(p1, 0.).distance_sq <= 4. * TOL_2 + { + callback.callback(LinePathEl::LineTo(p2)); + } else { + let q = QuadBez::new(p0, p1, p2); + let params = q.estimate_subdiv(SQRT_TOL); + let n = ((0.5 / SQRT_TOL * params.val).ceil() as usize).max(1); + let step = 1.0 / (n as f64); + for i in 1..n { + let u = (i as f64) * step; + let t = q.determine_subdiv_t(¶ms, u); + let p = q.eval(t); + callback.callback(LinePathEl::LineTo(p)); + } + callback.callback(LinePathEl::LineTo(p2)); + } + last_pt = p2; + } + PathEl::CurveTo(p1, p2, p3) => { + let p0 = last_pt; + let line = Line::new(p0, p3); + // If the cubic Bézier is fully to the right, top, or bottom of the culling bbox, + // it does not impact pixel coverage or winding. We can ignore it. The following + // checks that conservatively by checking whether the bounding box of the Bézier's + // control points is fully outside the culling bbox. + if [p0, p1, p2, p3].into_iter().all(|p| p.x > right) + || [p0, p1, p2, p3].into_iter().all(|p| p.y < top) + || [p0, p1, p2, p3].into_iter().all(|p| p.y > bottom) + { + callback.callback(LinePathEl::MoveTo(p3)); + } + // The following checks two things. First, if the cubic Bézier is fully to the left + // of the culling bbox, it may affect pixel coverage and winding, but its exact + // shape does not matter. It can be emitted as a line segment [p0, p3]. + // + // Second, an upper bound on the shortest distance of any point on the cubic Bézier + // curve to the line segment [p0, p3] is 3/4 of the maximum of the + // control-point-to-line-segment distances. + // + // With Bernstein weights Bi(t), we have + // c(t) = B0(t) p0 + B1(t) p1 + B2(t) p2 + B3(t) p3 + // with t from 0 to 1 (inclusive). + // + // Through convexivity of the Euclidean distance function and the line segment, + // we have + // dist(c(t), [p0, p3]) <= B1(t) dist(p1, [p0, p3]) + B2(t) dist(p2, [p0, p3]) + // <= (B1(t) + B2(t)) max(dist(p1, [p0, p3]), dist(p2, [p0, p3])) + // = 3 ((1-t)t^2 + (1-t)^2t) max(dist(p1, [p0, p3]), dist(p2, [p0, p3])). + // + // The inner polynomial has its maximum of 1/4 at t=1/2, hence + // max(dist(c(t), [p0, p3])) <= 3/4 max(dist(p1, [p0, p3]), dist(p2, [p0, p3])). + // + // The following takes the square to elide the square root of the Euclidean + // distance. + else if [p0, p1, p2, p3].into_iter().all(|p| p.x < left) + || f64::max( + line.nearest(p1, 0.).distance_sq, + line.nearest(p2, 0.).distance_sq, + ) <= 16. / 9. * TOL_2 + { + callback.callback(LinePathEl::LineTo(p3)); + } else { + let c = CubicBez::new(p0, p1, p2, p3); + let max = flatten_cubic_simd(simd, c, flatten_ctx); + + for p in &flatten_ctx.flattened_cubics[1..max] { + callback.callback(LinePathEl::LineTo(Point::new(p.x as f64, p.y as f64))); + } + } + last_pt = p3; + } + PathEl::ClosePath => { + if last_pt != start_pt { + callback.callback(LinePathEl::LineTo(start_pt)); + + // Kurbo says: "If `quad_to` [or another drawing op] is called immediately + // after `close_path` then the current subpath starts at the initial point of + // the previous subpath." + // + // Hence, we set `last_pt` back to the just-closed subpath's `start_pt`. + last_pt = start_pt; + } + } + } + } + + if last_pt != start_pt { + callback.callback(LinePathEl::LineTo(start_pt)); + } +} + +// The below methods are copied from kurbo and needed to implement flattening of normal quad curves. + +/// An approximation to $\int (1 + 4x^2) ^ -0.25 dx$ +/// +/// This is used for flattening curves. +fn approx_parabola_integral(x: f64) -> f64 { + const D: f64 = 0.67; + x / (1.0 - D + (D.powi(4) + 0.25 * x * x).sqrt().sqrt()) +} + +/// An approximation to the inverse parabola integral. +fn approx_parabola_inv_integral(x: f64) -> f64 { + const B: f64 = 0.39; + x * (1.0 - B + (B * B + 0.25 * x * x).sqrt()) +} + +impl FlattenParamsExt for QuadBez { + #[inline(always)] + fn estimate_subdiv(&self, sqrt_tol: f64) -> FlattenParams { + // Determine transformation to $y = x^2$ parabola. + let d01 = self.p1 - self.p0; + let d12 = self.p2 - self.p1; + let dd = d01 - d12; + let cross = (self.p2 - self.p0).cross(dd); + let x0 = d01.dot(dd) * cross.recip(); + let x2 = d12.dot(dd) * cross.recip(); + let scale = (cross / (dd.hypot() * (x2 - x0))).abs(); + + // Compute number of subdivisions needed. + let a0 = approx_parabola_integral(x0); + let a2 = approx_parabola_integral(x2); + let val = if scale.is_finite() { + let da = (a2 - a0).abs(); + let sqrt_scale = scale.sqrt(); + if x0.signum() == x2.signum() { + da * sqrt_scale + } else { + // Handle cusp case (segment contains curvature maximum) + let xmin = sqrt_tol / sqrt_scale; + sqrt_tol * da / approx_parabola_integral(xmin) + } + } else { + 0.0 + }; + let u0 = approx_parabola_inv_integral(a0); + let u2 = approx_parabola_inv_integral(a2); + let uscale = (u2 - u0).recip(); + FlattenParams { + a0, + a2, + u0, + uscale, + val, + } + } + + #[inline(always)] + fn determine_subdiv_t(&self, params: &FlattenParams, x: f64) -> f64 { + let a = params.a0 + (params.a2 - params.a0) * x; + let u = approx_parabola_inv_integral(a); + (u - params.u0) * params.uscale + } +} + +trait FlattenParamsExt { + fn estimate_subdiv(&self, sqrt_tol: f64) -> FlattenParams; + fn determine_subdiv_t(&self, params: &FlattenParams, x: f64) -> f64; +} + +// Everything below is a SIMD implementation of flattening of cubic curves. +// It's a combination of https://gist.github.com/raphlinus/5f4e9feb85fd79bafc72da744571ec0e +// and https://gist.github.com/raphlinus/44e114fef2fd33b889383a60ced0129b. + +// TODO(laurenz): Perhaps we should get rid of this in the future and work directly with f32, +// as it's the only reason we have to pull in proc_macros via the `derive` feature of bytemuck. +#[derive(Clone, Copy, Debug, Default, Zeroable, Pod)] +#[repr(C)] +struct Point32 { + x: f32, + y: f32, +} + +struct FlattenParams { + a0: f64, + a2: f64, + u0: f64, + uscale: f64, + /// The number of `subdivisions * 2 * sqrt_tol`. + val: f64, +} + +/// This limit was chosen based on the pre-existing GitHub gist. +/// This limit should not be hit in normal operation, but _might_ be hit for very large +/// transforms. +const MAX_QUADS: usize = 16; + +/// The context needed for flattening curves. +#[derive(Default, Debug)] +pub struct FlattenCtx { + // The +4 is to encourage alignment; might be better to be explicit + even_pts: [Point32; MAX_QUADS + 4], + odd_pts: [Point32; MAX_QUADS], + a0: [f32; MAX_QUADS], + da: [f32; MAX_QUADS], + u0: [f32; MAX_QUADS], + uscale: [f32; MAX_QUADS], + val: [f32; MAX_QUADS], + n_quads: usize, + /// Reusable buffer for flattened cubic points. + flattened_cubics: Vec, +} + +#[inline(always)] +fn is_finite_simd(x: f32x4) -> mask32x4 { + let simd = x.simd; + + let x_abs = x.abs(); + let reinterpreted = u32x4::from_bytes(x_abs.to_bytes()); + simd.simd_lt_u32x4(reinterpreted, u32x4::splat(simd, 0x7f80_0000)) +} + +/// An approximation to $\int (1 + 4x^2) ^ -0.25 dx$ +/// +/// This is used for flattening curves. +/// +/// SIMD version of [`approx_parabola_integral`]. +#[inline(always)] +fn approx_parabola_integral_simd>(x: F) -> F { + let simd = x.witness(); + + const D: f32 = 0.67; + const D_POWI_4: f32 = 0.201_511_2; + + let temp = F::splat(x.witness(), 0.25) + .mul_add(x * x, F::splat(simd, D_POWI_4)) + .sqrt() + .sqrt(); + let denom = temp + (1. - D); + x / denom +} + +/// An approximation to the inverse parabola integral. +/// +/// SIMD version of [`approx_parabola_inv_integral`]. +#[inline(always)] +fn approx_parabola_inv_integral_simd(x: f32x8) -> f32x8 { + let simd = x.simd; + + const B: f32 = 0.39; + const ONE_MINUS_B: f32 = 1.0 - B; + + let temp = f32x8::splat(simd, 0.25).mul_add(x * x, B * B).sqrt(); + let factor = f32x8::splat(simd, ONE_MINUS_B) + temp; + x * factor +} + +#[inline(always)] +fn pt_splat_simd(simd: S, pt: Point32) -> f32x8 { + let p_f64: f64 = bytemuck::cast(pt); + simd.reinterpret_f32_f64x4(f64x4::splat(simd, p_f64)) +} + +#[inline(always)] +fn eval_cubics_simd(simd: S, c: &CubicBez, n: usize, result: &mut FlattenCtx) { + result.n_quads = n; + let dt = 0.5 / n as f32; + + // TODO(laurenz): Perhaps we can SIMDify this better + let p0p1 = f32x4::from_slice( + simd, + &[c.p0.x as f32, c.p0.y as f32, c.p1.x as f32, c.p1.y as f32], + ); + let p2p3 = f32x4::from_slice( + simd, + &[c.p2.x as f32, c.p2.y as f32, c.p3.x as f32, c.p3.y as f32], + ); + + let split_single = |input: f32x4| { + let t1 = simd.reinterpret_f64_f32x4(input); + let p0 = simd.zip_low_f64x2(t1, t1); + let p1 = simd.zip_high_f64x2(t1, t1); + + let p0 = simd.reinterpret_f32_f64x2(p0); + let p1 = simd.reinterpret_f32_f64x2(p1); + + (f32x8::block_splat(p0), f32x8::block_splat(p1)) + }; + + let (p0_128, p1_128) = split_single(p0p1); + let (p2_128, p3_128) = split_single(p2p3); + + // Use Horner's method to evaluate p(t) as ((a*t + b)*t + c)*t + d. This allows hoisting + // coefficients out of the loop, and then evaluating as three sequential FMAs. Estrin's method + // would expose more ILP, but the increase in work on such small polynomials (cubic here and + // quad below) makes it a net slowdown. + let coeff_a = (p1_128 - p2_128).mul_add(3.0, p3_128 - p0_128); + let coeff_b = p1_128.mul_add(-2.0, p0_128 + p2_128) * 3.0; + let coeff_c = (p1_128 - p0_128) * 3.0; + let coeff_d = p0_128; + + let iota = f32x8::from_slice(simd, &[0.0, 0.0, 2.0, 2.0, 1.0, 1.0, 3.0, 3.0]); + let step = iota * dt; + let mut t = step; + let t_inc = f32x8::splat(simd, 4.0 * dt); + + let even_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut result.even_pts); + let odd_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut result.odd_pts); + + for i in 0..n.div_ceil(2) { + let evaluated = (coeff_a.mul_add(t, coeff_b)) + .mul_add(t, coeff_c) + .mul_add(t, coeff_d); + + let (low, high) = simd.split_f32x8(evaluated); + + low.store_slice(&mut even_pts[i * 4..][..4]); + high.store_slice(&mut odd_pts[i * 4..][..4]); + + t += t_inc; + } + + p3_128.store_slice(&mut even_pts[n * 2..][..8]); +} + +#[inline(always)] +fn estimate_subdiv_simd(simd: S, sqrt_tol: f32, ctx: &mut FlattenCtx) { + let n = ctx.n_quads; + + let even_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.even_pts); + let odd_pts: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.odd_pts); + + for i in 0..n.div_ceil(4) { + let p0 = f32x8::from_slice(simd, &even_pts[i * 8..][..8]); + let p_onehalf = f32x8::from_slice(simd, &odd_pts[i * 8..][..8]); + let p2 = f32x8::from_slice(simd, &even_pts[(i * 8 + 2)..][..8]); + let x = p0 * -0.5; + let x1 = p_onehalf.mul_add(2.0, x); + let p1 = p2.mul_add(-0.5, x1); + + p1.store_slice(&mut odd_pts[(i * 8)..][..8]); + + let d01 = p1 - p0; + let d12 = p2 - p1; + let d01x = simd.unzip_low_f32x8(d01, d01); + let d01y = simd.unzip_high_f32x8(d01, d01); + let d12x = simd.unzip_low_f32x8(d12, d12); + let d12y = simd.unzip_high_f32x8(d12, d12); + let ddx = d01x - d12x; + let ddy = d01y - d12y; + let d02x = d01x + d12x; + let d02y = d01y + d12y; + // (d02x * ddy) - (d02y * ddx) + let cross = ddx.mul_add(-d02y, d02x * ddy); + + let x0_x2_a = { + let (d01x_low, _) = simd.split_f32x8(d01x); + let (d12x_low, _) = simd.split_f32x8(d12x); + + simd.combine_f32x4(d12x_low, d01x_low) * ddx + }; + let temp1 = { + let (d12y_low, _) = simd.split_f32x8(d12y); + let (d01y_low, _) = simd.split_f32x8(d01y); + + simd.combine_f32x4(d12y_low, d01y_low) + }; + let x0_x2_num = temp1.mul_add(ddy, x0_x2_a); + let x0_x2 = x0_x2_num / cross; + let (ddx_low, _) = simd.split_f32x8(ddx); + let (ddy_low, _) = simd.split_f32x8(ddy); + let dd_hypot = ddy_low.mul_add(ddy_low, ddx_low * ddx_low).sqrt(); + let (x0, x2) = simd.split_f32x8(x0_x2); + let scale_denom = dd_hypot * (x2 - x0); + let (temp2, _) = simd.split_f32x8(cross); + let scale = (temp2 / scale_denom).abs(); + let a0_a2 = approx_parabola_integral_simd(x0_x2); + let (a0, a2) = simd.split_f32x8(a0_a2); + let da = a2 - a0; + let da_abs = da.abs(); + let sqrt_scale = scale.sqrt(); + let temp3 = simd.or_i32x4(x0.bitcast(), x2.bitcast()); + let mask = simd.simd_ge_i32x4(temp3, i32x4::splat(simd, 0)); + let noncusp = da_abs * sqrt_scale; + // TODO: should we skip this if neither is a cusp? Maybe not worth branch prediction cost + let xmin = sqrt_tol / sqrt_scale; + let approxint = approx_parabola_integral_simd(xmin); + let cusp = (sqrt_tol * da_abs) / approxint; + let val_raw = simd.select_f32x4(mask, noncusp, cusp); + let finite_mask = is_finite_simd(val_raw); + let val = simd.select_f32x4(finite_mask, val_raw, f32x4::splat(simd, 0.0)); + let u0_u2 = approx_parabola_inv_integral_simd(a0_a2); + let (u0, u2) = simd.split_f32x8(u0_u2); + let uscale_a = u2 - u0; + let uscale = 1.0 / uscale_a; + + a0.store_slice(&mut ctx.a0[i * 4..][..4]); + da.store_slice(&mut ctx.da[i * 4..][..4]); + u0.store_slice(&mut ctx.u0[i * 4..][..4]); + uscale.store_slice(&mut ctx.uscale[i * 4..][..4]); + val.store_slice(&mut ctx.val[i * 4..][..4]); + } +} + +#[inline(always)] +fn output_lines_simd( + simd: S, + ctx: &mut FlattenCtx, + i: usize, + x0: f32, + dx: f32, + n: usize, + start_idx: usize, +) { + let p0 = pt_splat_simd(simd, ctx.even_pts[i]); + let p1 = pt_splat_simd(simd, ctx.odd_pts[i]); + let p2 = pt_splat_simd(simd, ctx.even_pts[i + 1]); + + const IOTA2: [f32; 8] = [0., 0., 1., 1., 2., 2., 3., 3.]; + let iota2 = f32x8::from_slice(simd, IOTA2.as_ref()); + let x = iota2.mul_add(dx, f32x8::splat(simd, x0)); + let da = f32x8::splat(simd, ctx.da[i]); + let mut a = da.mul_add(x, f32x8::splat(simd, ctx.a0[i])); + let a_inc = 4.0 * dx * da; + let uscale = f32x8::splat(simd, ctx.uscale[i]); + let u0 = f32x8::splat(simd, ctx.u0[i]); + + // See Horner comment above + let coeff_a = p1.mul_add(-2.0, p0) + p2; + let coeff_b = (p1 - p0) * 2.0; + let coeff_c = p0; + + let out: &mut [f32] = bytemuck::cast_slice_mut(&mut ctx.flattened_cubics[start_idx..]); + + for j in 0..n.div_ceil(4) { + let u = approx_parabola_inv_integral_simd(a); + let t = (u - u0) * uscale; + let p = coeff_a.mul_add(t, coeff_b).mul_add(t, coeff_c); + p.store_slice(&mut out[j * 8..][..8]); + a += a_inc; + } +} + +#[inline(always)] +fn flatten_cubic_simd(simd: S, c: CubicBez, ctx: &mut FlattenCtx) -> usize { + let n_quads = estimate_num_quads(c, TOL as f32); + eval_cubics_simd(simd, &c, n_quads, ctx); + let tol = (TOL as f32) * (1.0 - TO_QUAD_TOL); + let sqrt_tol = tol.sqrt(); + estimate_subdiv_simd(simd, sqrt_tol, ctx); + let sum: f32 = ctx.val[..n_quads].iter().sum(); + let n = ((0.5 * sum / sqrt_tol).ceil() as usize).max(1); + let target_len = n + 4; + if target_len > ctx.flattened_cubics.len() { + ctx.flattened_cubics.resize(target_len, Point32::default()); + } + + let step = sum / (n as f32); + let step_recip = 1.0 / step; + let mut val_sum = 0.0; + let mut last_n = 0; + let mut x0base = 0.0; + + for i in 0..n_quads { + let val = ctx.val[i]; + val_sum += val; + let this_n = val_sum * step_recip; + let this_n_next = 1.0 + this_n.floor(); + let dn = this_n_next as usize - last_n; + if dn > 0 { + let dx = step / val; + let x0 = x0base * dx; + output_lines_simd(simd, ctx, i, x0, dx, dn, last_n); + } + x0base = this_n_next - this_n; + last_n = this_n_next as usize; + } + + ctx.flattened_cubics[n] = ctx.even_pts[n_quads]; + + n + 1 +} + +#[inline(always)] +fn estimate_num_quads(c: CubicBez, accuracy: f32) -> usize { + let q_accuracy = (accuracy * TO_QUAD_TOL) as f64; + let max_hypot2 = 432.0 * q_accuracy * q_accuracy; + let p1x2 = c.p1.to_vec2() * 3.0 - c.p0.to_vec2(); + let p2x2 = c.p2.to_vec2() * 3.0 - c.p3.to_vec2(); + let err = (p2x2 - p1x2).hypot2(); + let err_div = err / max_hypot2; + + estimate(err_div) +} + +const TO_QUAD_TOL: f32 = 0.1; + +#[inline(always)] +fn estimate(err_div: f64) -> usize { + // The original version of this method was: + // let n_quads = (err_div.powf(1. / 6.0).ceil() as usize).max(1); + // n_quads.min(MAX_QUADS) + // + // Note how we always round up and clamp to the range [1, max_quads]. Since we don't + // care about the actual fractional value resulting from the powf call we can simply + // compute this using a precomputed lookup table evaluating 1^6, 2^6, 3^6, etc. and simply + // comparing if the value is less than or equal to each threshold. + + const LUT: [f64; MAX_QUADS] = [ + 1.0, 64.0, 729.0, 4096.0, 15625.0, 46656.0, 117649.0, 262144.0, 531441.0, 1000000.0, + 1771561.0, 2985984.0, 4826809.0, 7529536.0, 11390625.0, 16777216.0, + ]; + + #[expect(clippy::needless_range_loop, reason = "better clarity")] + for i in 0..MAX_QUADS { + if err_div <= LUT[i] { + return i + 1; + } + } + + MAX_QUADS +} + +#[cfg(test)] +mod tests { + use crate::flatten_simd::{MAX_QUADS, estimate}; + + fn old_estimate(err_div: f64) -> usize { + let n_quads = (err_div.powf(1. / 6.0).ceil() as usize).max(1); + n_quads.min(MAX_QUADS) + } + + // Test is disabled by default since it takes 10-20 seconds to run, even in release mode. + #[test] + #[ignore] + fn accuracy() { + for i in 0..u32::MAX { + let num = f32::from_bits(i); + + if num.is_finite() { + assert_eq!(old_estimate(num as f64), estimate(num as f64), "{num}"); + } + } + } +} diff --git a/vendor/vello_common/src/geometry.rs b/vendor/vello_common/src/geometry.rs new file mode 100644 index 0000000000..4337550eb2 --- /dev/null +++ b/vendor/vello_common/src/geometry.rs @@ -0,0 +1,108 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Geometry utilities. + +/// An axis-aligned rectangle with `u16` coordinates, stored as two corners `(x0, y0)` and +/// `(x1, y1)`. +/// +/// `(x0, y0)` is the top-left (minimum) corner and `(x1, y1)` is the bottom-right (maximum) corner. +/// The rectangle is considered to be empty when `x0 >= x1` or `y0 >= y1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RectU16 { + /// The minimum x coordinate (left edge). + pub x0: u16, + /// The minimum y coordinate (top edge). + pub y0: u16, + /// The maximum x coordinate (right edge, exclusive). + pub x1: u16, + /// The maximum y coordinate (bottom edge, exclusive). + pub y1: u16, +} + +impl RectU16 { + /// A rectangle with all coordinates set to zero. + pub const ZERO: Self = Self { + x0: 0, + y0: 0, + x1: 0, + y1: 0, + }; + + /// An empty, maximally inverted rectangle, useful as a starting value for incremental union + /// operations. + /// + /// Has `(x0, y0) = (u16::MAX, u16::MAX)` and `(x1, y1) = (0, 0)`. + pub const INVERTED: Self = Self { + x0: u16::MAX, + y0: u16::MAX, + x1: 0, + y1: 0, + }; + + /// Create a new rectangle from its corner coordinates. + #[inline(always)] + pub const fn new(x0: u16, y0: u16, x1: u16, y1: u16) -> Self { + Self { x0, y0, x1, y1 } + } + + /// The width of the rectangle (`x1 - x0`), saturating at zero. + #[inline(always)] + pub const fn width(self) -> u16 { + self.x1.saturating_sub(self.x0) + } + + /// The height of the rectangle (`y1 - y0`), saturating at zero. + #[inline(always)] + pub const fn height(self) -> u16 { + self.y1.saturating_sub(self.y0) + } + + /// Returns `true` if the rectangle has zero area (`x0 >= x1` or `y0 >= y1`). + #[inline(always)] + pub const fn is_empty(self) -> bool { + self.x0 >= self.x1 || self.y0 >= self.y1 + } + + /// Check if a point `(x, y)` is contained within this rectangle. + /// + /// Returns `true` if `x0 <= x < x1` and `y0 <= y < y1`. + #[inline(always)] + pub const fn contains(self, x: u16, y: u16) -> bool { + (x >= self.x0) & (x < self.x1) & (y >= self.y0) & (y < self.y1) + } + + /// Compute the intersection of two rectangles. + /// + /// The result may be empty if the rectangles do not overlap. + #[inline(always)] + pub const fn intersect(self, other: Self) -> Self { + Self { + x0: const_max(self.x0, other.x0), + y0: const_max(self.y0, other.y0), + x1: const_min(self.x1, other.x1), + y1: const_min(self.y1, other.y1), + } + } + + /// Expand this rectangle to also cover `other` (union in place). + /// + /// The union of `self` with a [`Self::INVERTED`] returns `self`. + #[inline(always)] + pub const fn union(&mut self, other: Self) { + self.x0 = const_min(self.x0, other.x0); + self.y0 = const_min(self.y0, other.y0); + self.x1 = const_max(self.x1, other.x1); + self.y1 = const_max(self.y1, other.y1); + } +} + +#[inline(always)] +const fn const_max(a: u16, b: u16) -> u16 { + if a > b { a } else { b } +} + +#[inline(always)] +const fn const_min(a: u16, b: u16) -> u16 { + if a < b { a } else { b } +} diff --git a/vendor/vello_common/src/image_cache.rs b/vendor/vello_common/src/image_cache.rs new file mode 100644 index 0000000000..f19c42f7ad --- /dev/null +++ b/vendor/vello_common/src/image_cache.rs @@ -0,0 +1,342 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Image resource caching with multi-atlas allocation. +//! +//! This module provides an [`ImageCache`] that manages image resources across multiple texture +//! atlases, supporting allocation, deallocation, and slot reuse. + +use crate::multi_atlas::{ + AllocId, AllocationStrategy, AtlasConfig, AtlasError, AtlasId, MultiAtlasManager, +}; +use crate::paint::ImageId; +use alloc::vec::Vec; + +/// Represents an image resource for rendering. +#[derive(Debug)] +pub struct ImageResource { + /// The width of the image. + pub width: u16, + /// The height of the image. + pub height: u16, + /// The Id of the atlas containing this image. + pub atlas_id: AtlasId, + /// The offset of the image within its atlas (does not include padding, i.e. it points to the + /// position of the first actual top-left pixel). + pub offset: [u16; 2], + /// The number of transparent padding pixels around the image in the atlas. + pub padding: u16, + /// The atlas allocation ID for deallocation. + atlas_alloc_id: AllocId, +} + +impl ImageResource { + /// Returns the offset as `[u32; 2]`. + pub fn offsets(&self) -> [u32; 2] { + [self.offset[0] as u32, self.offset[1] as u32] + } + + /// Returns the size as `[u32; 2]`. + pub fn size(&self) -> [u32; 2] { + [self.width as u32, self.height as u32] + } +} + +/// Manages image resources for the renderer. +pub struct ImageCache { + /// Multi-atlas manager for handling multiple texture atlases. + atlas_manager: MultiAtlasManager, + /// Vector of optional image resources (None = free slot). + slots: Vec>, + /// Stack of free indices. + free_idxs: Vec, +} + +impl core::fmt::Debug for ImageCache { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let atlas_stats = self.atlas_manager.atlas_stats(); + + f.debug_struct("ImageCache") + .field("slots", &self.slots) + .field("free_idxs", &self.free_idxs) + .field("atlas_count", &self.atlas_manager.atlas_count()) + .field("atlas_stats", &atlas_stats) + .finish() + } +} + +impl ImageCache { + /// Create a new image cache with custom atlas configuration. + pub fn new_with_config(config: AtlasConfig) -> Self { + Self { + atlas_manager: MultiAtlasManager::new(config), + slots: Vec::new(), + free_idxs: Vec::new(), + } + } + + /// Create a new dummy image atlas that is supposed to act as a stub. + pub fn new_dummy() -> Self { + Self::new_with_config(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 1, + atlas_size: (1, 1), + auto_grow: false, + allocation_strategy: AllocationStrategy::FirstFit, + }) + } + + /// Get an image resource by its Id. + pub fn get(&self, id: ImageId) -> Option<&ImageResource> { + self.slots.get(id.as_u32() as usize)?.as_ref() + } + + /// Allocate an image in the cache, with optional transparent padding. + pub fn allocate( + &mut self, + width: u32, + height: u32, + padding: u16, + ) -> Result { + self.allocate_excluding(width, height, padding, None) + } + + /// Allocate an image in the cache, with optional transparency padding + /// and optionally excluding a specific atlas. + #[expect( + clippy::cast_possible_truncation, + reason = "u16 is enough for the offset and width/height" + )] + pub fn allocate_excluding( + &mut self, + width: u32, + height: u32, + padding: u16, + exclude_atlas_id: Option, + ) -> Result { + let padded_width = width + u32::from(padding) * 2; + let padded_height = height + u32::from(padding) * 2; + let atlas_alloc = self.atlas_manager.try_allocate_excluding( + padded_width, + padded_height, + exclude_atlas_id, + )?; + + let slot_idx = self.free_idxs.pop().unwrap_or_else(|| { + // No free slots, append to vector + let index = self.slots.len(); + // Placeholder, will be replaced + self.slots.push(None); + index + }); + + let image_id = ImageId::new(slot_idx as u32); + let image_resource = ImageResource { + width: width as u16, + height: height as u16, + atlas_id: atlas_alloc.atlas_id, + offset: [ + atlas_alloc.allocation.x as u16 + padding, + atlas_alloc.allocation.y as u16 + padding, + ], + padding, + atlas_alloc_id: atlas_alloc.allocation.id, + }; + self.slots[slot_idx] = Some(image_resource); + + Ok(image_id) + } + + /// Deallocate an image from the cache, returning the image resource if it existed. + pub fn deallocate(&mut self, id: ImageId) -> Option { + let index = id.as_u32() as usize; + if let Some(image_resource) = self.slots.get_mut(index).and_then(Option::take) { + // Deallocate from the appropriate atlas + let padded_width = image_resource.width as u32 + u32::from(image_resource.padding) * 2; + let padded_height = + image_resource.height as u32 + u32::from(image_resource.padding) * 2; + self.atlas_manager + .deallocate( + image_resource.atlas_id, + image_resource.atlas_alloc_id, + padded_width, + padded_height, + ) + .unwrap(); + self.free_idxs.push(index); + Some(image_resource) + } else { + None + } + } + + /// Get access to the atlas manager. + pub fn atlas_manager(&self) -> &MultiAtlasManager { + &self.atlas_manager + } + + /// Get the number of atlases. + pub fn atlas_count(&self) -> usize { + self.atlas_manager.atlas_count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ATLAS_SIZE: u32 = 1024; + + #[test] + fn test_insert_single_image() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id = cache.allocate(100, 100, 0).unwrap(); + + assert_eq!(id.as_u32(), 0); + let resource = cache.get(id).unwrap(); + assert_eq!(resource.width, 100); + assert_eq!(resource.height, 100); + // First image should be at origin + assert_eq!(resource.offset, [0, 0]); + } + + #[test] + fn test_insert_single_image_with_padding() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id = cache.allocate(100, 100, 4).unwrap(); + + assert_eq!(id.as_u32(), 0); + let resource = cache.get(id).unwrap(); + assert_eq!(resource.width, 100); + assert_eq!(resource.height, 100); + assert_eq!(resource.padding, 4); + // Offset should be shifted inward by padding. + assert_eq!(resource.offset, [4, 4]); + } + + #[test] + fn test_insert_multiple_images() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id1 = cache.allocate(50, 50, 0).unwrap(); + let id2 = cache.allocate(75, 75, 0).unwrap(); + + assert_eq!(id1.as_u32(), 0); + assert_eq!(id2.as_u32(), 1); + + let resource1 = cache.get(id1).unwrap(); + let resource2 = cache.get(id2).unwrap(); + + assert_eq!(resource1.width, 50); + assert_eq!(resource2.width, 75); + + // Second image should be placed adjacent to first + assert_ne!(resource1.offset, resource2.offset); + } + + #[test] + fn test_get_nonexistent_image() { + let cache: ImageCache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + assert!(cache.get(ImageId::new(0)).is_none()); + assert!(cache.get(ImageId::new(999)).is_none()); + } + + #[test] + fn test_remove_image() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + let id = cache.allocate(100, 100, 0).unwrap(); + assert!(cache.get(id).is_some()); + + cache.deallocate(id); + assert!(cache.get(id).is_none()); + } + + #[test] + fn test_remove_nonexistent_image() { + let mut cache: ImageCache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + // Should not panic when unregistering non-existent image + cache.deallocate(ImageId::new(0)); + cache.deallocate(ImageId::new(999)); + } + + #[test] + fn test_slot_reuse_after_remove() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + // Register three images + let id1 = cache.allocate(50, 50, 0).unwrap(); + let id2 = cache.allocate(60, 60, 0).unwrap(); + let id3 = cache.allocate(70, 70, 0).unwrap(); + + assert_eq!(id1.as_u32(), 0); + assert_eq!(id2.as_u32(), 1); + assert_eq!(id3.as_u32(), 2); + + // Unregister the middle one + cache.deallocate(id2); + assert!(cache.get(id2).is_none()); + + // Register a new image - should reuse slot 1 + let id4 = cache.allocate(80, 80, 0).unwrap(); + // Reused slot 1 + assert_eq!(id4.as_u32(), 1); + + // Verify other images are still there + assert!(cache.get(id1).is_some()); + assert!(cache.get(id3).is_some()); + assert!(cache.get(id4).is_some()); + assert_eq!(cache.get(id4).unwrap().width, 80); + } + + #[test] + fn test_multiple_remove_and_reuse() { + let mut cache = ImageCache::new_with_config(AtlasConfig { + atlas_size: (ATLAS_SIZE, ATLAS_SIZE), + ..Default::default() + }); + + // Register several images + let ids: Vec<_> = (0..5) + .map(|i| cache.allocate(100 + i * 10, 100 + i * 10, 0).unwrap()) + .collect(); + + // Unregister some in the middle + cache.deallocate(ids[1]); + cache.deallocate(ids[3]); + + // Register new images - should reuse the freed slots + let new_id1 = cache.allocate(200, 200, 0).unwrap(); + let new_id2 = cache.allocate(300, 300, 0).unwrap(); + + // Should have reused slots 3 and 1 (in reverse order due to stack behavior) + assert_eq!(new_id1.as_u32(), 3); + assert_eq!(new_id2.as_u32(), 1); + assert_ne!(new_id1.as_u32(), new_id2.as_u32()); + } +} diff --git a/vendor/vello_common/src/lib.rs b/vendor/vello_common/src/lib.rs new file mode 100644 index 0000000000..6862ea431d --- /dev/null +++ b/vendor/vello_common/src/lib.rs @@ -0,0 +1,106 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +// After you edit the crate's doc comment, run this command, then check README.md for any missing links +// cargo rdme --workspace-project=vello_common + +//! This crate includes common geometry representations, tiling logic, and other fundamental components used by both [Vello CPU][vello_cpu] and Vello Hybrid. +//! +//! # Usage +//! +//! This crate should not be used on its own, and you should instead use one of the renderers which use it. +//! At the moment, only [Vello CPU][vello_cpu] is published, and you probably want to use that. +//! +//! We also develop [Vello](https://crates.io/crates/vello), which makes use of the GPU for 2D rendering and has higher performance than Vello CPU. +//! Vello CPU is being developed as part of work to address shortcomings in Vello. +//! Vello does not use this crate. +//! +//! # Features +//! +//! - `std` (enabled by default): Get floating point functions from the standard library +//! (likely using your target's libc). +//! - `libm`: Use floating point implementations from [libm][]. +//! - `png` (enabled by default): Allow loading [`Pixmap`][crate::pixmap::Pixmap]s from PNG images. +//! Also required for rendering glyphs with an embedded PNG. +//! Implies `std`. +//! +//! At least one of `std` and `libm` is required; `std` overrides `libm`. +//! +//! # Contents +//! +//! - Shared data structures for paths, tiles, and strips +//! - Geometry processing utilities +//! - Common logic for rendering stages +//! +//! This crate acts as a foundation for `vello_cpu` and `vello_hybrid`, providing essential components to minimize duplication. +//! +//! [vello_cpu]: https://crates.io/crates/vello_cpu +#![cfg_attr(feature = "libm", doc = "[libm]: libm")] +#![cfg_attr(not(feature = "libm"), doc = "[libm]: https://crates.io/crates/libm")] +// LINEBENDER LINT SET - lib.rs - v3 +// See https://linebender.org/wiki/canonical-lints/ +// These lints shouldn't apply to examples or tests. +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +// These lints shouldn't apply to examples. +#![warn(clippy::print_stdout, clippy::print_stderr)] +// Targeting e.g. 32-bit means structs containing usize can give false positives for 64-bit. +#![cfg_attr(target_pointer_width = "64", warn(clippy::trivially_copy_pass_by_ref))] +// END LINEBENDER LINT SET +#![cfg_attr(docsrs, feature(doc_cfg))] +#![forbid(unsafe_code)] +#![expect( + clippy::cast_possible_truncation, + reason = "We temporarily ignore those because the casts\ +only break in edge cases, and some of them are also only related to conversions from f64 to f32." +)] +#![no_std] + +// Suppress the unused_crate_dependencies lint when both std and libm are specified. +#[cfg(all(feature = "std", feature = "libm"))] +use libm as _; + +extern crate alloc; +#[cfg(feature = "std")] +extern crate std; + +pub mod blurred_rounded_rect; +pub mod clip; +pub mod coarse; +pub mod encode; +pub mod filter; +pub mod filter_effects; +pub mod flatten; +pub(crate) mod flatten_simd; +pub mod geometry; +pub mod image_cache; +pub mod mask; +pub mod math; +pub mod multi_atlas; +pub mod paint; +#[doc(hidden)] +#[cfg(feature = "pico_svg")] +pub mod pico_svg; +pub mod pixmap; +#[doc(hidden)] +#[cfg(feature = "probe")] +pub mod probe; +pub mod rect; +pub mod render_graph; +pub mod render_state; +pub mod simd; +pub mod strip; +pub mod strip_generator; +pub mod tile; +pub mod util; + +pub use fearless_simd; +pub use peniko; +pub use peniko::color; +pub use peniko::kurbo; + +/// A handle to an external, user-provided texture. +/// +/// This is resolved at render time by passing in a mapping of handles to textures, but is +/// otherwise opaque to the renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TextureId(pub u64); diff --git a/vendor/vello_common/src/mask.rs b/vendor/vello_common/src/mask.rs new file mode 100644 index 0000000000..8047be8287 --- /dev/null +++ b/vendor/vello_common/src/mask.rs @@ -0,0 +1,116 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Alpha and luminance masks. + +use crate::pixmap::Pixmap; +use alloc::sync::Arc; +use alloc::vec::Vec; + +#[derive(Debug, PartialEq, Eq)] +struct MaskRepr { + data: Vec, + width: u16, + height: u16, +} + +// Note that we are on purpose storing width and height inside the `Arc` +// to reduce the memory footprint of the struct. +/// A mask. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mask(Arc); + +impl Mask { + /// Create a new alpha mask from the pixmap. + pub fn new_alpha(pixmap: &Pixmap) -> Self { + Self::new_with(pixmap, true) + } + + /// Create a new luminance mask from the pixmap. + pub fn new_luminance(pixmap: &Pixmap) -> Self { + Self::new_with(pixmap, false) + } + + /// Create a new mask from the given alpha data. + /// + /// The `data` vector must be of length `width * height` exactly. + /// + /// The pixels are in row-major order. + /// + /// # Panics + /// + /// Panics if the `data` vector is not of length `width * height`. + pub fn from_parts(data: Vec, width: u16, height: u16) -> Self { + assert_eq!( + data.len(), + usize::from(width) * usize::from(height), + "`data` should have `width * height` length" + ); + + Self(Arc::new(MaskRepr { + data, + width, + height, + })) + } + + fn new_with(pixmap: &Pixmap, alpha_mask: bool) -> Self { + let data = pixmap + .data() + .iter() + .map(|pixel| { + if alpha_mask { + pixel.a + } else { + let r = f32::from(pixel.r) / 255.; + let g = f32::from(pixel.g) / 255.; + let b = f32::from(pixel.b) / 255.; + + // See CSS Masking Module Level 1 § 7.10.1 + // + // and Filter Effects Module Level 1 § 9.6 + // . + // Note r, g and b are premultiplied by alpha. + let luma = r * 0.2126 + g * 0.7152 + b * 0.0722; + #[expect(clippy::cast_possible_truncation, reason = "This cannot overflow")] + { + (luma * 255.0 + 0.5) as u8 + } + } + }) + .collect::>(); + + Self(Arc::new(MaskRepr { + data, + width: pixmap.width(), + height: pixmap.height(), + })) + } + + /// Return the width of the mask. + #[inline(always)] + pub fn width(&self) -> u16 { + self.0.width + } + + /// Return the height of the mask. + #[inline(always)] + pub fn height(&self) -> u16 { + self.0.height + } + + /// Sample the value at a specific location. + /// + /// This function might panic or yield a wrong result if the location + /// is out-of-bounds. + #[inline(always)] + pub fn sample(&self, x: u16, y: u16) -> u8 { + let repr = &*self.0; + debug_assert!( + x < repr.width && y < repr.height, + "cannot sample mask outside of its range" + ); + + repr.data[y as usize * repr.width as usize + x as usize] + } +} diff --git a/vendor/vello_common/src/math.rs b/vendor/vello_common/src/math.rs new file mode 100644 index 0000000000..36efbacaef --- /dev/null +++ b/vendor/vello_common/src/math.rs @@ -0,0 +1,60 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Mathematical helper functions. + +use core::ops::Sub; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +// See https://raphlinus.github.io/audio/2018/09/05/sigmoid.html for a little +// explanation of this approximation to the erf function. +/// Approximate the erf function. +pub fn compute_erf7(x: f32) -> f32 { + // Clamp `x`, because for large `x` the terms here become `inf`, causing the result to be 0 or + // `NaN`. This clamping doesn't lose any information, because `erf(±10) ≈ 1` well within `f64` + // machine precision, let alone `f32`. + let x = x.clamp(-10., 10.); + let x = x * core::f32::consts::FRAC_2_SQRT_PI; + let xx = x * x; + let x = x + (0.24295 + (0.03395 + 0.0104 * xx) * xx) * (x * xx); + x / (1.0 + x * x).sqrt() +} + +// From +// Note: If this value changes, also update NEARLY_ZERO_TOLERANCE in render_strips.wgsl +// @see {@link https://github.com/linebender/vello/blob/58b80d660e2fc5aef3bd32b24af3f95e973aab95/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl#L63} +const SCALAR_NEARLY_ZERO: f32 = 1.0 / (1 << 12) as f32; + +/// A number of useful methods for floating-point numbers. +pub trait FloatExt: Sized + Sub { + /// Whether the number is approximately 0. + fn is_nearly_zero(&self) -> bool; + + /// Whether the number is approximately 0, with a given tolerance. + fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool; +} + +impl FloatExt for f32 { + fn is_nearly_zero(&self) -> bool { + self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO) + } + + fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool { + debug_assert!(tolerance >= 0.0, "tolerance must be positive"); + + self.abs() <= tolerance + } +} + +impl FloatExt for f64 { + fn is_nearly_zero(&self) -> bool { + self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO) + } + + fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool { + debug_assert!(tolerance >= 0.0, "tolerance must be positive"); + + self.abs() <= tolerance as Self + } +} diff --git a/vendor/vello_common/src/multi_atlas.rs b/vendor/vello_common/src/multi_atlas.rs new file mode 100644 index 0000000000..4e42918e29 --- /dev/null +++ b/vendor/vello_common/src/multi_atlas.rs @@ -0,0 +1,760 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Multi-atlas management for texture atlases. +//! +//! This module provides support for managing multiple texture atlases, allowing for handling of +//! large numbers of images. +//! +//! The allocator backend is [guillotiere](https://github.com/nical/guillotiere)'s tree-based +//! guillotine algorithm, providing O(1) neighbor lookup during deallocation and automatic +//! free-rect coalescing. + +use alloc::vec::Vec; +pub use guillotiere::AllocId; +use guillotiere::AtlasAllocator; +use thiserror::Error; + +/// The result of a successful rectangle allocation within a single atlas. +#[derive(Debug)] +pub struct Allocation { + /// Opaque handle used for deallocation. + pub id: AllocId, + /// X coordinate of the top-left corner of the allocated rectangle. + pub x: u32, + /// Y coordinate of the top-left corner of the allocated rectangle. + pub y: u32, +} + +// --------------------------------------------------------------------------- +// Unified Atlas type +// --------------------------------------------------------------------------- + +/// Represents a single atlas in the multi-atlas system. +pub struct Atlas { + /// Unique identifier for this atlas. + pub id: AtlasId, + /// Rectangle allocator backend. + allocator: AtlasAllocator, + /// Current usage statistics. + stats: AtlasUsageStats, + /// Allocation counter. + allocation_counter: u32, +} + +impl Atlas { + /// Create a new atlas with the given ID and size. + pub fn new(id: AtlasId, width: u32, height: u32) -> Self { + Self { + id, + allocator: AtlasAllocator::new(guillotiere::size2(width as i32, height as i32)), + stats: AtlasUsageStats { + allocated_area: 0, + total_area: width * height, + allocated_count: 0, + }, + allocation_counter: 0, + } + } + + /// Try to allocate an image in this atlas. + #[expect( + clippy::cast_sign_loss, + reason = "coordinates are always non-negative for valid allocations" + )] + pub fn allocate(&mut self, width: u32, height: u32) -> Option { + let alloc = self + .allocator + .allocate(guillotiere::size2(width as i32, height as i32))?; + self.stats.allocated_area += width * height; + self.stats.allocated_count += 1; + self.allocation_counter += 1; + Some(Allocation { + id: alloc.id, + x: alloc.rectangle.min.x as u32, + y: alloc.rectangle.min.y as u32, + }) + } + + /// Deallocate an image from this atlas. + pub fn deallocate(&mut self, alloc_id: AllocId, width: u32, height: u32) { + self.allocator.deallocate(alloc_id); + self.stats.allocated_area = self.stats.allocated_area.saturating_sub(width * height); + self.stats.allocated_count = self.stats.allocated_count.saturating_sub(1); + } + + /// Get current usage statistics. + pub fn stats(&self) -> &AtlasUsageStats { + &self.stats + } +} + +impl core::fmt::Debug for Atlas { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Atlas") + .field("id", &self.id) + .field("stats", &self.stats) + .field("allocation_counter", &self.allocation_counter) + .finish_non_exhaustive() + } +} + +// --------------------------------------------------------------------------- +// MultiAtlasManager +// --------------------------------------------------------------------------- + +/// Manages multiple texture atlases. +pub struct MultiAtlasManager { + /// All atlases managed by this instance. + atlases: Vec, + /// Configuration for atlas management. + config: AtlasConfig, + /// Round-robin counter for allocation strategy. + round_robin_counter: usize, +} + +impl MultiAtlasManager { + /// Create a new multi-atlas manager with the given configuration. + pub fn new(config: AtlasConfig) -> Self { + let mut manager = Self { + atlases: Vec::new(), + config, + round_robin_counter: 0, + }; + + for _ in 0..config.initial_atlas_count { + manager + .create_atlas() + .expect("Failed to create initial atlas"); + } + + manager + } + + /// Get the current configuration. + pub fn config(&self) -> &AtlasConfig { + &self.config + } + + /// Create a new atlas and return its ID. + pub fn create_atlas(&mut self) -> Result { + if self.atlases.len() >= self.config.max_atlases { + return Err(AtlasError::AtlasLimitReached); + } + + let atlas_id = AtlasId::new(self.next_atlas_id()); + + let atlas = Atlas::new(atlas_id, self.config.atlas_size.0, self.config.atlas_size.1); + self.atlases.push(atlas); + + Ok(atlas_id) + } + + /// Get the next available atlas ID. + pub fn next_atlas_id(&self) -> u32 { + u32::try_from(self.atlases.len()).unwrap() + } + + /// Try to allocate space for an image with the given dimensions. + pub fn try_allocate(&mut self, width: u32, height: u32) -> Result { + self.try_allocate_excluding(width, height, None) + } + + /// Try to allocate space for an image with the given dimensions, + /// optionally excluding a specific atlas. + pub fn try_allocate_excluding( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + // Check if the image is too large for any atlas + if width > self.config.atlas_size.0 || height > self.config.atlas_size.1 { + return Err(AtlasError::TextureTooLarge { width, height }); + } + + // Try allocation based on strategy + match self.config.allocation_strategy { + AllocationStrategy::FirstFit => { + self.allocate_first_fit(width, height, exclude_atlas_id) + } + AllocationStrategy::BestFit => self.allocate_best_fit(width, height, exclude_atlas_id), + AllocationStrategy::LeastUsed => { + self.allocate_least_used(width, height, exclude_atlas_id) + } + AllocationStrategy::RoundRobin => { + self.allocate_round_robin(width, height, exclude_atlas_id) + } + } + } + + /// Allocate using first-fit strategy: try atlases in order until one has space. + fn allocate_first_fit( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + for atlas in &mut self.atlases { + if Some(atlas.id) == exclude_atlas_id { + continue; + } + + if let Some(allocation) = atlas.allocate(width, height) { + return Ok(AtlasAllocation { + atlas_id: atlas.id, + allocation, + }); + } + } + + // Try creating a new atlas if auto-grow is enabled + if self.config.auto_grow { + let atlas_id = self.create_atlas()?; + let atlas = self.atlases.last_mut().unwrap(); + if let Some(allocation) = atlas.allocate(width, height) { + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + } + + Err(AtlasError::NoSpaceAvailable) + } + + /// Allocate using best-fit strategy: choose the atlas with the smallest remaining space that + /// can fit the image. + fn allocate_best_fit( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + let mut best_atlas_idx = None; + let mut best_remaining_space = u32::MAX; + + // Find the atlas with the least remaining space that can fit the image + for (idx, atlas) in self.atlases.iter().enumerate() { + if Some(atlas.id) == exclude_atlas_id { + continue; + } + + let stats = atlas.stats(); + let remaining_space = stats.total_area - stats.allocated_area; + + if remaining_space >= width * height && remaining_space < best_remaining_space { + best_remaining_space = remaining_space; + best_atlas_idx = Some(idx); + } + } + + if let Some(idx) = best_atlas_idx { + let atlas = &mut self.atlases[idx]; + if let Some(allocation) = atlas.allocate(width, height) { + return Ok(AtlasAllocation { + atlas_id: atlas.id, + allocation, + }); + } + } + + // Fallback to first-fit if best-fit didn't work + self.allocate_first_fit(width, height, exclude_atlas_id) + } + + /// Allocate using least-used strategy: prefer the atlas with the lowest usage percentage. + fn allocate_least_used( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + let mut best_atlas_idx = None; + let mut lowest_usage = f32::MAX; + + // Find the atlas with the lowest usage percentage + for (idx, atlas) in self.atlases.iter().enumerate() { + if Some(atlas.id) == exclude_atlas_id { + continue; + } + + let usage = atlas.stats().usage_percentage(); + if usage < lowest_usage { + lowest_usage = usage; + best_atlas_idx = Some(idx); + } + } + + if let Some(idx) = best_atlas_idx + && let Some(allocation) = self.atlases[idx].allocate(width, height) + { + let atlas_id = self.atlases[idx].id; + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + + // Fallback to first-fit if least-used didn't work + self.allocate_first_fit(width, height, exclude_atlas_id) + } + + /// Allocate using round-robin strategy: cycle through atlases using a round-robin counter. + fn allocate_round_robin( + &mut self, + width: u32, + height: u32, + exclude_atlas_id: Option, + ) -> Result { + if self.atlases.is_empty() { + return self.allocate_first_fit(width, height, exclude_atlas_id); + } + + let start_idx = self.round_robin_counter % self.atlases.len(); + + // Try starting from the round-robin position + for i in 0..self.atlases.len() { + let idx = (start_idx + i) % self.atlases.len(); + + if Some(self.atlases[idx].id) == exclude_atlas_id { + continue; + } + + if let Some(allocation) = self.atlases[idx].allocate(width, height) { + let atlas_id = self.atlases[idx].id; + self.round_robin_counter = (idx + 1) % self.atlases.len(); + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + } + + // Try creating a new atlas if auto-grow is enabled + if self.config.auto_grow { + let atlas_id = self.create_atlas()?; + let atlas = self.atlases.last_mut().unwrap(); + if let Some(allocation) = atlas.allocate(width, height) { + self.round_robin_counter = self.atlases.len() - 1; + return Ok(AtlasAllocation { + atlas_id, + allocation, + }); + } + } + + Err(AtlasError::NoSpaceAvailable) + } + + /// Deallocate space in the specified atlas. + pub fn deallocate( + &mut self, + atlas_id: AtlasId, + alloc_id: AllocId, + width: u32, + height: u32, + ) -> Result<(), AtlasError> { + // Since atlases only grow (never deallocate) and id is the index into the atlases vec, + // we can do a lookup instead of a linear search + let atlas = self + .atlases + .get_mut(atlas_id.0 as usize) + .ok_or(AtlasError::AtlasNotFound(atlas_id))?; + atlas.deallocate(alloc_id, width, height); + Ok(()) + } + + /// Get statistics for all atlases. + pub fn atlas_stats(&self) -> Vec<(AtlasId, &AtlasUsageStats)> { + self.atlases + .iter() + .map(|atlas| (atlas.id, atlas.stats())) + .collect() + } + + /// Get the number of atlases. + pub fn atlas_count(&self) -> usize { + self.atlases.len() + } +} + +impl core::fmt::Debug for MultiAtlasManager { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("MultiAtlasManager") + .field("atlas_count", &self.atlases.len()) + .field("config", &self.config) + .field("next_atlas_id", &self.next_atlas_id()) + .field("round_robin_counter", &self.round_robin_counter) + .field("atlases", &self.atlases) + .finish() + } +} + +/// Errors that can occur during atlas operations. +#[derive(Debug, Clone, Error)] +pub enum AtlasError { + /// No space available in any atlas. + #[error("No space available in any atlas")] + NoSpaceAvailable, + /// Maximum number of atlases reached. + #[error("Maximum number of atlases reached")] + AtlasLimitReached, + /// The requested texture size is too large for any atlas. + #[error("Texture too large ({width}x{height}) for atlas")] + TextureTooLarge { + /// The width of the requested texture. + width: u32, + /// The height of the requested texture. + height: u32, + }, + /// The specified atlas was not found. + #[error("Atlas with Id {0:?} not found")] + AtlasNotFound(AtlasId), +} + +/// Unique identifier for an atlas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AtlasId(pub u32); + +impl AtlasId { + /// Create a new atlas ID. + pub fn new(id: u32) -> Self { + Self(id) + } + + /// Get the raw ID value. + pub fn as_u32(self) -> u32 { + self.0 + } +} + +/// Usage statistics for an atlas. +#[derive(Debug, Clone)] +pub struct AtlasUsageStats { + /// Total allocated area in pixels. + pub allocated_area: u32, + /// Total available area in pixels. + pub total_area: u32, + /// Number of allocated images. + pub allocated_count: u32, +} + +impl AtlasUsageStats { + /// Calculate usage percentage (0.0 to 1.0). + pub fn usage_percentage(&self) -> f32 { + if self.total_area == 0 { + 0.0 + } else { + self.allocated_area as f32 / self.total_area as f32 + } + } +} + +/// Result of an atlas allocation attempt. +#[derive(Debug)] +pub struct AtlasAllocation { + /// The atlas where the allocation was made. + pub atlas_id: AtlasId, + /// The allocation details. + pub allocation: Allocation, +} + +/// Configuration for multiple atlas support. +/// +/// Note that any values provided here are recommendations and might not be fully +/// honored depending on the capabilities of the backend. For example, if you define +/// the atlas size to be 8192x8192 but the device only supports texture sizes up to 4096x4096, +/// the backend will likely decide to instead use the value that is compatible with the device. +#[derive(Debug, Clone, Copy)] +pub struct AtlasConfig { + /// Initial number of atlases to create. + pub initial_atlas_count: usize, + /// Maximum number of atlases to create. + pub max_atlases: usize, + // TODO: Make those u16 instead? + /// Size of each atlas texture. + pub atlas_size: (u32, u32), + /// Whether to automatically create new atlases when needed. + pub auto_grow: bool, + /// Strategy for allocating images across atlases. + pub allocation_strategy: AllocationStrategy, +} + +impl Default for AtlasConfig { + fn default() -> Self { + Self { + initial_atlas_count: 1, + max_atlases: 8, + atlas_size: (4096, 4096), + auto_grow: true, + allocation_strategy: AllocationStrategy::FirstFit, + } + } +} + +/// Strategy for allocating images across multiple atlases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AllocationStrategy { + /// Try atlases in order until one has space. + #[default] + FirstFit, + /// Choose the atlas with the smallest remaining space that can fit the image. + BestFit, + /// Prefer the atlas with the lowest usage percentage. + LeastUsed, + /// Cycle through atlases in round-robin fashion. + RoundRobin, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_atlas_creation() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 0, + ..Default::default() + }); + + let atlas_id = manager.create_atlas().unwrap(); + assert_eq!(atlas_id.as_u32(), 0); + assert_eq!(manager.atlas_count(), 1); + } + + #[test] + fn test_allocation_strategies() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: true, + }); + + // Should create atlas automatically + let allocation = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation.atlas_id.as_u32(), 0); + } + + #[test] + fn test_atlas_limit() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 1, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: false, + }); + + assert!(manager.create_atlas().is_err()); + } + + #[test] + fn test_texture_too_large() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + atlas_size: (256, 256), + ..Default::default() + }); + + let result = manager.try_allocate(300, 300); + assert!(matches!(result, Err(AtlasError::TextureTooLarge { .. }))); + } + + #[test] + fn test_first_fit_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: false, + }); + + // First allocation should go to atlas 0 + let allocation0 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + // Second allocation should also go to atlas 0 (first fit) + let allocation1 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 0); + + // Third allocation should still go to atlas 0 (first fit continues to use same atlas) + let allocation2 = manager.try_allocate(80, 80).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 0); + + // Try to allocate something very large that definitely won't fit in atlas 0's remaining space + // This should force it to go to atlas 1 + let allocation3 = manager.try_allocate(200, 200).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 1); + + // Next small allocation should go back to atlas 0 (first fit tries atlas 0 first) + let allocation4 = manager.try_allocate(20, 20).unwrap(); + assert_eq!(allocation4.atlas_id.as_u32(), 0); + } + + #[test] + fn test_best_fit_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::BestFit, + auto_grow: false, + }); + + // All atlases start empty, so first allocation goes to atlas 0 (first available) + let allocation0 = manager.try_allocate(150, 150).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + // Second allocation should also go to atlas 0 since it still has the least remaining space + // that can fit the image (all atlases have same remaining space, so it picks the first) + let allocation1 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 0); + + // Now atlas 0 has less remaining space than atlases 1 and 2 + // For a small allocation, it should still go to atlas 0 (best fit - least remaining space) + let allocation2 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 0); + + // Now try to allocate something very large that won't fit in atlas 0's remaining space + // This should force it to go to atlas 1 (which has the most remaining space) + let allocation3 = manager.try_allocate(200, 200).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 1); + + // Now atlas 1 has less remaining space + // A small allocation should go to atlas 0 as it can + let allocation4 = manager.try_allocate(80, 80).unwrap(); + assert_eq!(allocation4.atlas_id.as_u32(), 0); + + // Now atlas 1 has less remaining space but it can't fit the allocation + // It should go to atlas 2 (best fit - least remaining space) + let allocation5 = manager.try_allocate(80, 80).unwrap(); + assert_eq!(allocation5.atlas_id.as_u32(), 2); + } + + #[test] + fn test_least_used_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::LeastUsed, + auto_grow: false, + }); + + // First allocation goes to atlas 0 (all atlases have 0% usage, picks first) + let allocation0 = manager.try_allocate(100, 100).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + // Second allocation should go to atlas 1 (least used among remaining) + let allocation1 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 1); + + // Third allocation should go to atlas 2 (least used) + let allocation2 = manager.try_allocate(30, 30).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 2); + + // Fourth allocation should go to atlas 2 again (still least used) + let allocation3 = manager.try_allocate(30, 30).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 2); + } + + #[test] + fn test_round_robin_allocation_strategy() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::RoundRobin, + auto_grow: false, + }); + + // Allocations should cycle through atlases in order + let allocation0 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + let allocation1 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 1); + + let allocation2 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 2); + + // Should wrap back to atlas 0 + let allocation3 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation3.atlas_id.as_u32(), 0); + + // Continue the cycle + let allocation4 = manager.try_allocate(50, 50).unwrap(); + assert_eq!(allocation4.atlas_id.as_u32(), 1); + } + + #[test] + fn test_auto_grow() { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 1, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: AllocationStrategy::FirstFit, + auto_grow: true, + }); + + let allocation0 = manager.try_allocate(256, 256).unwrap(); + assert_eq!(allocation0.atlas_id.as_u32(), 0); + + let allocation1 = manager.try_allocate(256, 256).unwrap(); + assert_eq!(allocation1.atlas_id.as_u32(), 1); + + let allocation2 = manager.try_allocate(256, 256).unwrap(); + assert_eq!(allocation2.atlas_id.as_u32(), 2); + } + + fn test_allocate_excluding_with_strategy(strategy: AllocationStrategy) { + let mut manager = MultiAtlasManager::new(AtlasConfig { + initial_atlas_count: 3, + max_atlases: 3, + atlas_size: (256, 256), + allocation_strategy: strategy, + auto_grow: false, + }); + + let allocation0 = manager.try_allocate(100, 100).unwrap(); + let first_atlas = allocation0.atlas_id; + let allocation1 = manager + .try_allocate_excluding(256, 256, Some(first_atlas)) + .unwrap(); + assert_ne!(allocation1.atlas_id, first_atlas); + + let second_atlas = allocation1.atlas_id; + let allocation2 = manager + .try_allocate_excluding(100, 100, Some(second_atlas)) + .unwrap(); + assert_ne!(allocation2.atlas_id, second_atlas); + + let allocation3 = manager + .try_allocate_excluding(100, 100, Some(first_atlas)) + .unwrap(); + assert_ne!(allocation3.atlas_id, first_atlas); + } + + #[test] + fn test_allocate_excluding_first_fit() { + test_allocate_excluding_with_strategy(AllocationStrategy::FirstFit); + } + + #[test] + fn test_allocate_excluding_best_fit() { + test_allocate_excluding_with_strategy(AllocationStrategy::BestFit); + } + + #[test] + fn test_allocate_excluding_least_used() { + test_allocate_excluding_with_strategy(AllocationStrategy::LeastUsed); + } + + #[test] + fn test_allocate_excluding_round_robin() { + test_allocate_excluding_with_strategy(AllocationStrategy::RoundRobin); + } +} diff --git a/vendor/vello_common/src/paint.rs b/vendor/vello_common/src/paint.rs new file mode 100644 index 0000000000..b032bc31b5 --- /dev/null +++ b/vendor/vello_common/src/paint.rs @@ -0,0 +1,276 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Types for paints. + +use crate::pixmap::Pixmap; +use alloc::sync::Arc; +pub use peniko::Color; +use peniko::{ + Gradient, + color::{AlphaColor, PremulRgba8, Srgb}, +}; + +/// A paint that needs to be resolved via its index. +// In the future, we might add additional flags, that's why we have +// this thin wrapper around u32, so we can change the underlying +// representation without breaking the API. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IndexedPaint(u32); + +impl IndexedPaint { + /// Create a new indexed paint from an index. + pub fn new(index: usize) -> Self { + Self(u32::try_from(index).expect("exceeded the maximum number of paints")) + } + + /// Return the index of the paint. + pub fn index(&self) -> usize { + usize::try_from(self.0).unwrap() + } +} + +/// A paint that is used internally by a rendering frontend to store how a wide tile command +/// should be painted. There are only two types of paint: +/// +/// 1) Simple solid colors, which are stored in premultiplied representation so that +/// each wide tile doesn't have to recompute it. +/// 2) Indexed paints, which can represent any arbitrary, more complex paint that is +/// determined by the frontend. The intended way of using this is to store a vector +/// of paints and store its index inside `IndexedPaint`. +#[derive(Debug, Clone, PartialEq)] +pub enum Paint { + /// A premultiplied RGBA8 color. + Solid(PremulColor), + /// A paint that needs to be resolved via an index. + Indexed(IndexedPaint), +} + +impl From> for Paint { + fn from(value: AlphaColor) -> Self { + Self::Solid(PremulColor::from_alpha_color(value)) + } +} + +/// Opaque image handle +#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug)] +pub struct ImageId(u32); + +impl ImageId { + // TODO: make this private in future + /// Create a new image id from a u32. + pub fn new(value: u32) -> Self { + Self(value) + } + + /// Return the image id as a u32. + pub fn as_u32(&self) -> u32 { + self.0 + } +} + +/// Bitmap source used by `Image`. +#[derive(Debug, Clone)] +pub enum ImageSource { + /// Pixmap pixels travel with the scene packet. + Pixmap(Arc), + /// Pixmap pixels were registered earlier; this is just a handle. + OpaqueId { + /// The image handle. + id: ImageId, + /// Whether the image may contain non-opaque pixels. + may_have_transparency: bool, + }, +} + +impl ImageSource { + /// Create an [`ImageSource`] from a pre-registered image handle. + /// + /// Conservatively assumes the image may have non-opaque pixels. + /// Use [`Self::opaque_id_with_transparency_hint`] when you know the image is fully opaque. + pub fn opaque_id(id: ImageId) -> Self { + Self::OpaqueId { + id, + may_have_transparency: true, + } + } + + /// Create an [`ImageSource`] from a pre-registered image handle, + /// with an explicit hint about whether the image may have non-opaque pixels. + pub fn opaque_id_with_transparency_hint(id: ImageId, may_have_transparency: bool) -> Self { + Self::OpaqueId { + id, + may_have_transparency, + } + } + + /// Returns whether this image source may contain non-opaque pixels. + pub fn may_have_transparency(&self) -> bool { + match self { + Self::Pixmap(p) => p.may_have_transparency(), + Self::OpaqueId { + may_have_transparency, + .. + } => *may_have_transparency, + } + } + + /// Convert a [`peniko::ImageData`] to an [`ImageSource`]. + /// + /// This is a somewhat lossy conversion, as the image data data is transformed to + /// [premultiplied RGBA8](`PremulRgba8`). + /// + /// # Panics + /// + /// This panics if `image` has a `width` or `height` greater than `u16::MAX`. + pub fn from_peniko_image_data(image: &peniko::ImageData) -> Self { + // TODO: how do we deal with `peniko::ImageFormat` growing? See also + // . + let do_alpha_multiply = image.alpha_type != peniko::ImageAlphaType::AlphaPremultiplied; + + assert!( + image.width <= u16::MAX as u32 && image.height <= u16::MAX as u32, + "The image is too big. Its width and height can be no larger than {} pixels.", + u16::MAX, + ); + let width = image.width.try_into().unwrap(); + let height = image.height.try_into().unwrap(); + + // TODO: SIMD + #[expect(clippy::cast_possible_truncation, reason = "This cannot overflow.")] + let pixels = image + .data + .data() + .chunks_exact(4) + .map(|pixel| { + let rgba: [u8; 4] = match image.format { + peniko::ImageFormat::Rgba8 => pixel.try_into().unwrap(), + peniko::ImageFormat::Bgra8 => [pixel[2], pixel[1], pixel[0], pixel[3]], + format => unimplemented!("Unsupported image format: {format:?}"), + }; + let alpha = u16::from(rgba[3]); + let multiply = |component| ((alpha * u16::from(component)) / 255) as u8; + if do_alpha_multiply { + PremulRgba8 { + r: multiply(rgba[0]), + g: multiply(rgba[1]), + b: multiply(rgba[2]), + a: rgba[3], + } + } else { + PremulRgba8 { + r: rgba[0], + g: rgba[1], + b: rgba[2], + a: rgba[3], + } + } + }) + .collect(); + let pixmap = Pixmap::from_parts(pixels, width, height); + + Self::Pixmap(Arc::new(pixmap)) + } +} + +/// An image. +pub type Image = peniko::ImageBrush; + +/// Trait for resolving opaque image IDs to pixmaps at rasterization time. +/// +/// This allows delaying the resolution of `ImageSource::OpaqueId` until the +/// image is actually needed during rasterization, enabling patterns like +/// dynamic sprite atlases where the image data may be updated between +/// encoding and rendering. +pub trait ImageResolver: Send + Sync { + /// Resolve an `ImageId` to its pixmap data. + /// + /// This method may be called repeatedly (dozens or even hundreds of times + /// per frame) and should therefore be very fast. + /// + /// Returns `None` if the image ID is not found in the registry. + fn resolve(&self, id: ImageId) -> Option>; +} + +/// A no-op image resolver that always returns `None`. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoOpImageResolver; + +impl ImageResolver for NoOpImageResolver { + fn resolve(&self, _id: ImageId) -> Option> { + None + } +} + +/// A premultiplied color. +#[derive(Debug, Clone, PartialEq, Copy)] +pub struct PremulColor { + premul_u8: PremulRgba8, + premul_f32: peniko::color::PremulColor, +} + +impl PremulColor { + /// Create a new premultiplied color. + pub fn from_alpha_color(color: AlphaColor) -> Self { + Self::from_premul_color(color.premultiply()) + } + + /// Create a new premultiplied color from `peniko::PremulColor`. + pub fn from_premul_color(color: peniko::color::PremulColor) -> Self { + Self { + premul_u8: color.to_rgba8(), + premul_f32: color, + } + } + + /// Return the color as a premultiplied RGBA8 color. + pub fn as_premul_rgba8(&self) -> PremulRgba8 { + self.premul_u8 + } + + /// Return the color as a premultiplied RGBAF32 color. + pub fn as_premul_f32(&self) -> peniko::color::PremulColor { + self.premul_f32 + } + + /// Return whether the color is opaque (i.e. doesn't have transparency). + pub fn is_opaque(&self) -> bool { + self.premul_f32.components[3] == 1.0 + } +} + +/// How tint color is applied to an image. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum TintMode { + /// Alpha-mask tinting: `tint_premul * source.alpha`. + /// + /// The source image's alpha channel is used as a coverage mask, + /// and the result is filled with the premultiplied tint color. + /// This is the standard approach for glyph / monochrome image tinting. + AlphaMask = 0, + /// Component-wise multiply: `source * tint`. + /// + /// Each channel of the source pixel is multiplied by the corresponding + /// channel of the tint color. This works well for full-color images. + Multiply = 1, +} + +impl TintMode { + /// Return the discriminant as a `u32`. + pub fn as_u32(self) -> u32 { + self as u32 + } +} + +/// A tint applied to image paints. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Tint { + /// The tint color. + pub color: Color, + /// How the tint is applied. + pub mode: TintMode, +} + +/// A kind of paint that can be used for filling and stroking shapes. +pub type PaintType = peniko::Brush; diff --git a/vendor/vello_common/src/pico_svg.rs b/vendor/vello_common/src/pico_svg.rs new file mode 100644 index 0000000000..c9f3fdc312 --- /dev/null +++ b/vendor/vello_common/src/pico_svg.rs @@ -0,0 +1,356 @@ +// Copyright 2024 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +// Below is copied, lightly adapted, from Vello. + +//! A minimal SVG parser for rendering examples +//! +//! This module provides a simple SVG parser to load and render SVG files +//! for demonstration purposes. It supports basic SVG features like paths, +//! fill, stroke, and grouping. + +#![expect( + clippy::print_stderr, + reason = "We don't have a better way to handle these errors, plus this code is not public API" +)] + +extern crate std; + +use crate::color::{AlphaColor, DynamicColor, Srgb, palette}; +use crate::kurbo::{Affine, BezPath, Point, Size, Vec2}; +use alloc::boxed::Box; +use alloc::vec; +use alloc::vec::Vec; +use core::str::FromStr; +use roxmltree::{Document, Node}; +use std::eprintln; + +/// A simplified representation of an SVG document +#[derive(Debug)] +pub struct PicoSvg { + /// The items (shapes, groups) contained in the SVG + pub items: Vec, + /// The size of the SVG document + pub size: Size, +} + +/// Represents a single item in an SVG document +#[derive(Debug)] +pub enum Item { + /// A filled shape + Fill(FillItem), + /// A stroked shape + Stroke(StrokeItem), + /// A group of items + Group(GroupItem), +} + +/// A stroke item with styling information +#[derive(Debug)] +pub struct StrokeItem { + /// The width of the stroke + pub width: f64, + /// The color of the stroke + pub color: AlphaColor, + /// The path to be stroked + pub path: BezPath, +} + +/// A fill item with styling information +#[derive(Debug)] +pub struct FillItem { + /// The color to fill with + pub color: AlphaColor, + /// The path to be filled + pub path: BezPath, +} + +/// A group of items that can be transformed together +#[derive(Debug)] +pub struct GroupItem { + /// The affine transformation to apply to all children + pub affine: Affine, + /// The child items in this group + pub children: Vec, +} + +struct Parser { + scale: f64, +} + +impl PicoSvg { + /// Load an SVG document from a string + pub fn load(xml_string: &str, scale: f64) -> Result> { + let doc = Document::parse(xml_string)?; + let root = doc.root_element(); + let mut parser = Parser::new(scale); + let root_width = root.attribute("width").and_then(|s| f64::from_str(s).ok()); + let root_height = root.attribute("height").and_then(|s| f64::from_str(s).ok()); + let (origin, viewbox_size) = root + .attribute("viewBox") + .and_then(|vb_attr| { + let vs: Vec = vb_attr + .split(' ') + .map(|s| f64::from_str(s).unwrap()) + .collect(); + if let &[x, y, width, height] = vs.as_slice() { + Some((Point { x, y }, Size { width, height })) + } else { + None + } + }) + .unzip(); + + let mut transform = if let Some(origin) = origin { + Affine::translate(origin.to_vec2() * -1.0) + } else { + Affine::IDENTITY + }; + + transform *= match (root_width, root_height, viewbox_size) { + (None, None, Some(_)) => Affine::IDENTITY, + (Some(w), Some(h), Some(s)) => { + Affine::scale_non_uniform(1.0 / s.width * w, 1.0 / s.height * h) + } + (Some(w), None, Some(s)) => Affine::scale(1.0 / s.width * w), + (None, Some(h), Some(s)) => Affine::scale(1.0 / s.height * h), + _ => Affine::IDENTITY, + }; + + let size = match (root_width, root_height, viewbox_size) { + (None, None, Some(s)) => s, + (mw, mh, None) => Size { + width: mw.unwrap_or(300_f64), + height: mh.unwrap_or(150_f64), + }, + (Some(w), None, Some(s)) => Size { + width: w, + height: 1.0 / w * s.width * s.height, + }, + (None, Some(h), Some(s)) => Size { + width: 1.0 / h * s.height * s.width, + height: h, + }, + (Some(width), Some(height), Some(_)) => Size { width, height }, + }; + + transform *= if scale >= 0.0 { + Affine::scale(scale) + } else { + Affine::new([-scale, 0.0, 0.0, scale, 0.0, 0.0]) + }; + let props = RecursiveProperties { + fill: Some(palette::css::BLACK), + }; + // The root element is the svg document element, which we don't care about + let mut items = Vec::new(); + for node in root.children() { + parser.rec_parse(node, &props, &mut items)?; + } + let root_group = Item::Group(GroupItem { + affine: transform, + children: items, + }); + Ok(Self { + items: vec![root_group], + size, + }) + } +} + +#[derive(Clone)] +struct RecursiveProperties { + fill: Option>, +} + +impl Parser { + fn new(scale: f64) -> Self { + Self { scale } + } + + fn rec_parse( + &mut self, + node: Node<'_, '_>, + properties: &RecursiveProperties, + items: &mut Vec, + ) -> Result<(), Box> { + if node.is_element() { + let mut properties = properties.clone(); + if let Some(fill_color) = node.attribute("fill") { + if fill_color == "none" { + properties.fill = None; + } else { + let color = parse_color(fill_color); + let color = modify_opacity(color, "fill-opacity", node); + // TODO: Handle recursive opacity properly + let color = modify_opacity(color, "opacity", node); + properties.fill = Some(color); + } + } + match node.tag_name().name() { + "g" => { + let mut children = Vec::new(); + let mut affine = Affine::default(); + if let Some(transform) = node.attribute("transform") { + affine = parse_transform(transform); + } + for child in node.children() { + self.rec_parse(child, &properties, &mut children)?; + } + items.push(Item::Group(GroupItem { affine, children })); + } + "path" => { + let d = node.attribute("d").ok_or("missing 'd' attribute")?; + let bp = BezPath::from_svg(d)?; + let path = bp; + if let Some(color) = properties.fill { + items.push(Item::Fill(FillItem { + color, + path: path.clone(), + })); + } + if let Some(stroke_color) = node.attribute("stroke") + && stroke_color != "none" + { + let width = node + .attribute("stroke-width") + .map(|a| f64::from_str(a).unwrap_or(1.0)) + .unwrap_or(1.0) + * self.scale.abs(); + let color = parse_color(stroke_color); + let color = modify_opacity(color, "stroke-opacity", node); + // TODO: Handle recursive opacity properly + let color = modify_opacity(color, "opacity", node); + items.push(Item::Stroke(StrokeItem { width, color, path })); + } + } + other => eprintln!("Unhandled node type {other}"), + } + } + Ok(()) + } +} + +fn parse_transform(transform: &str) -> Affine { + let mut nt = Affine::IDENTITY; + for ts in transform.split(')').map(str::trim) { + nt *= if let Some(s) = ts.strip_prefix("matrix(") { + let vals = s + .split([',', ' ']) + .map(str::parse) + .collect::, _>>() + .expect("Could parse all values of 'matrix' as floats"); + Affine::new( + vals.try_into() + .expect("Should be six arguments to `matrix`"), + ) + } else if let Some(s) = ts.strip_prefix("translate(") { + if let Ok(vals) = s + .split([',', ' ']) + .map(str::trim) + .map(str::parse) + .collect::, _>>() + { + match vals.as_slice() { + &[x, y] => Affine::translate(Vec2 { x, y }), + _ => Affine::IDENTITY, + } + } else { + Affine::IDENTITY + } + } else if let Some(s) = ts.strip_prefix("scale(") { + if let Ok(vals) = s + .split([',', ' ']) + .map(str::trim) + .map(str::parse) + .collect::, _>>() + { + match *vals.as_slice() { + [x, y] => Affine::scale_non_uniform(x, y), + [x] => Affine::scale(x), + _ => Affine::IDENTITY, + } + } else { + Affine::IDENTITY + } + } else if let Some(s) = ts.strip_prefix("scaleX(") { + s.trim() + .parse() + .ok() + .map(|x| Affine::scale_non_uniform(x, 1.0)) + .unwrap_or(Affine::IDENTITY) + } else if let Some(s) = ts.strip_prefix("scaleY(") { + s.trim() + .parse() + .ok() + .map(|y| Affine::scale_non_uniform(1.0, y)) + .unwrap_or(Affine::IDENTITY) + } else { + if !ts.is_empty() { + eprintln!("Did not understand transform attribute {ts:?})"); + } + Affine::IDENTITY + }; + } + nt +} + +fn parse_color(color: &str) -> AlphaColor { + let color = color.trim(); + crate::color::parse_color(color.trim()) + .map(DynamicColor::to_alpha_color) + .unwrap_or(palette::css::FUCHSIA.with_alpha(0.5)) +} + +fn modify_opacity( + color: AlphaColor, + attr_name: &str, + node: Node<'_, '_>, +) -> AlphaColor { + if let Some(opacity) = node.attribute(attr_name) { + let alpha: f32 = if let Some(o) = opacity.strip_suffix('%') { + let pctg = o.parse().unwrap_or(100.0); + pctg * 0.01 + } else { + opacity.parse().unwrap_or(1.0) + }; + color.with_alpha(alpha.clamp(0., 1.)) + } else { + color + } +} + +#[cfg(test)] +mod tests { + use super::parse_color; + use crate::color::{AlphaColor, Srgb, palette}; + + fn assert_close_color(c1: AlphaColor, c2: AlphaColor) { + const EPSILON: f32 = 1e-4; + assert_eq!(c1.cs, c2.cs); + for i in 0..4 { + assert!((c1.components[i] - c2.components[i]).abs() < EPSILON); + } + } + + #[test] + fn parse_colors() { + let lime = palette::css::LIME; + let lime_a = lime.with_alpha(0.4); + + let named = parse_color("lime"); + assert_close_color(lime, named); + + let hex = parse_color("#00ff00"); + assert_close_color(lime, hex); + + let rgb = parse_color("rgb(0, 255, 0)"); + assert_close_color(lime, rgb); + + let modern = parse_color("color(srgb 0 1 0)"); + assert_close_color(lime, modern); + + let modern_a = parse_color("color(srgb 0 1 0 / 0.4)"); + assert_close_color(lime_a, modern_a); + } +} diff --git a/vendor/vello_common/src/pixmap.rs b/vendor/vello_common/src/pixmap.rs new file mode 100644 index 0000000000..83055dd514 --- /dev/null +++ b/vendor/vello_common/src/pixmap.rs @@ -0,0 +1,378 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! A simple pixmap type. + +use alloc::vec; +use alloc::vec::Vec; +#[cfg(feature = "png")] +use std::io::{BufRead, Seek}; + +use crate::peniko::color::{PremulRgba8, Rgba8}; + +#[cfg(feature = "png")] +extern crate std; + +/// A pixmap of premultiplied RGBA8 values backed by [`u8`][core::u8]. +#[derive(Debug, Clone)] +pub struct Pixmap { + /// Width of the pixmap in pixels. + width: u16, + /// Height of the pixmap in pixels. + height: u16, + /// Buffer of the pixmap in RGBA8 format. + buf: Vec, + /// Whether the pixmap may have non-opaque pixels. + /// + /// Note: This may become stale if pixels are modified via [`data_mut()`](Self::data_mut), + /// [`data_as_u8_slice_mut()`](Self::data_as_u8_slice_mut), or [`set_pixel()`](Self::set_pixel). + may_have_transparency: bool, +} + +impl Pixmap { + /// Create a new pixmap with the given width and height in pixels. + /// + /// All pixels are initialized to transparent black. + pub fn new(width: u16, height: u16) -> Self { + let buf = vec![PremulRgba8::from_u32(0); width as usize * height as usize]; + Self { + width, + height, + buf, + may_have_transparency: true, + } + } + + /// Create a new pixmap with the given premultiplied RGBA8 data. + /// + /// The `data` vector must be of length `width * height` exactly. + /// + /// The pixels are in row-major order. + /// + /// This assumes the image may have transparent pixels. Use + /// [`from_parts_with_opacity`](Self::from_parts_with_opacity) if you already + /// know the opacity status to enable optimizations. + /// + /// # Panics + /// + /// Panics if the `data` vector is not of length `width * height`. + pub fn from_parts(data: Vec, width: u16, height: u16) -> Self { + Self::from_parts_with_opacity(data, width, height, true) + } + + /// Create a new pixmap with the given premultiplied RGBA8 data and precomputed opacity flag. + /// + /// The `data` vector must be of length `width * height` exactly. + /// + /// The pixels are in row-major order. + /// + /// Use this when you've already determined whether the data contains + /// non-opaque pixels to avoid redundant scanning. + /// + /// # Panics + /// + /// Panics if the `data` vector is not of length `width * height`. + pub fn from_parts_with_opacity( + data: Vec, + width: u16, + height: u16, + may_have_transparency: bool, + ) -> Self { + assert_eq!( + data.len(), + usize::from(width) * usize::from(height), + "Expected `data` to have length of exactly `width * height`" + ); + Self { + width, + height, + buf: data, + may_have_transparency, + } + } + + /// Resizes the pixmap container to the given width and height; this does not resize the + /// contained image. + /// + /// If the pixmap buffer has to grow to fit the new size, those pixels are set to transparent + /// black. If the pixmap buffer is larger than required, the buffer is truncated and its + /// reserved capacity is unchanged. + pub fn resize(&mut self, width: u16, height: u16) { + let new_len = usize::from(width) * usize::from(height); + // If we're growing, new pixels are transparent black + if new_len > self.buf.len() { + self.may_have_transparency = true; + } + self.width = width; + self.height = height; + self.buf.resize(new_len, PremulRgba8::from_u32(0)); + } + + /// Shrink the capacity of the pixmap buffer to fit the pixmap's current size. + pub fn shrink_to_fit(&mut self) { + self.buf.shrink_to_fit(); + } + + /// The reserved capacity (in pixels) of this pixmap. + /// + /// When calling [`Pixmap::resize`] with a `width * height` smaller than this value, the pixmap + /// does not need to reallocate. + pub fn capacity(&self) -> usize { + self.buf.capacity() + } + + /// Return the width of the pixmap. + pub fn width(&self) -> u16 { + self.width + } + + /// Return the height of the pixmap. + pub fn height(&self) -> u16 { + self.height + } + + /// Returns whether the pixmap may have non-opaque pixels. + /// + /// This value is computed at construction time. It may become stale if pixels are + /// modified directly via [`data_mut()`](Self::data_mut), + /// [`data_as_u8_slice_mut()`](Self::data_as_u8_slice_mut), or [`set_pixel()`](Self::set_pixel). + /// + /// Use [`set_may_have_transparency()`](Self::set_may_have_transparency) to manually update the flag, + /// or [`recompute_may_have_transparency()`](Self::recompute_may_have_transparency) to recalculate it + /// by scanning all pixels. + pub fn may_have_transparency(&self) -> bool { + self.may_have_transparency + } + + /// Manually set the `may_have_transparency` flag. + /// + /// Use this after modifying pixels via [`data_mut()`](Self::data_mut) or + /// [`set_pixel()`](Self::set_pixel) when you know whether the image has + /// non-opaque pixels. + pub fn set_may_have_transparency(&mut self, may_have_transparency: bool) { + self.may_have_transparency = may_have_transparency; + } + + /// Recalculate `may_have_transparency` by scanning all pixels. + /// + /// Use this after modifying pixels via [`data_mut()`](Self::data_mut) or + /// [`set_pixel()`](Self::set_pixel) when you need accurate opacity information. + pub fn recompute_may_have_transparency(&mut self) { + self.may_have_transparency = self.buf.iter().any(|pixel| pixel.a != 255); + } + + /// Apply an alpha value to the whole pixmap. + pub fn multiply_alpha(&mut self, alpha: u8) { + #[expect( + clippy::cast_possible_truncation, + reason = "cannot overflow in this case" + )] + let multiply = |component| ((u16::from(alpha) * u16::from(component)) / 255) as u8; + + for pixel in self.data_mut() { + *pixel = PremulRgba8 { + r: multiply(pixel.r), + g: multiply(pixel.g), + b: multiply(pixel.b), + a: multiply(pixel.a), + }; + } + + // If we applied a non-opaque alpha, the image now has transparency + if alpha != 255 { + self.may_have_transparency = true; + } + } + + /// Create a pixmap from a PNG file. + #[cfg(feature = "png")] + pub fn from_png(data: impl BufRead + Seek) -> Result { + let mut decoder = png::Decoder::new(data); + decoder.set_transformations( + png::Transformations::normalize_to_color8() | png::Transformations::ALPHA, + ); + + let mut reader = decoder.read_info()?; + let mut pixmap = { + let info = reader.info(); + let width: u16 = info + .width + .try_into() + .map_err(|_| png::DecodingError::LimitsExceeded)?; + let height: u16 = info + .height + .try_into() + .map_err(|_| png::DecodingError::LimitsExceeded)?; + Self::new(width, height) + }; + + // Note `reader.info()` returns the pre-transformation color type output, whereas + // `reader.output_color_type()` takes the transformation into account. + let (color_type, bit_depth) = reader.output_color_type(); + debug_assert_eq!( + bit_depth, + png::BitDepth::Eight, + "normalize_to_color8 means the bit depth is always 8." + ); + + match color_type { + png::ColorType::Rgb | png::ColorType::Grayscale => { + unreachable!("We set a transformation to always convert to alpha") + } + png::ColorType::Indexed => { + unreachable!("Transformation should have expanded indexed images") + } + png::ColorType::Rgba => { + debug_assert_eq!( + Some(pixmap.data_as_u8_slice().len()), + reader.output_buffer_size(), + "The pixmap buffer should have the same number of bytes as the image." + ); + reader.next_frame(pixmap.data_as_u8_slice_mut())?; + } + png::ColorType::GrayscaleAlpha => { + debug_assert_eq!( + Some(pixmap.data().len() * 2), + reader.output_buffer_size(), + "The pixmap buffer should have twice the number of bytes of the grayscale image." + ); + let mut grayscale_data = vec![0; reader.output_buffer_size().unwrap_or_default()]; + reader.next_frame(&mut grayscale_data)?; + + for (grayscale_pixel, pixmap_pixel) in + grayscale_data.chunks_exact(2).zip(pixmap.data_mut()) + { + let [gray, alpha] = grayscale_pixel.try_into().unwrap(); + *pixmap_pixel = PremulRgba8 { + r: gray, + g: gray, + b: gray, + a: alpha, + }; + } + } + }; + + let mut may_have_transparency = false; + for pixel in pixmap.data_mut() { + let alpha = pixel.a; + if alpha != 255 { + may_have_transparency = true; + } + let alpha_u16 = u16::from(alpha); + #[expect( + clippy::cast_possible_truncation, + reason = "Overflow should be impossible." + )] + let premultiply = |e: u8| ((u16::from(e) * alpha_u16) / 255) as u8; + pixel.r = premultiply(pixel.r); + pixel.g = premultiply(pixel.g); + pixel.b = premultiply(pixel.b); + } + pixmap.may_have_transparency = may_have_transparency; + + Ok(pixmap) + } + + /// Return the current content of the pixmap as a PNG. + #[cfg(feature = "png")] + pub fn into_png(self) -> Result, png::EncodingError> { + let mut data = Vec::new(); + let mut encoder = png::Encoder::new(&mut data, self.width as u32, self.height as u32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header()?; + writer.write_image_data(bytemuck::cast_slice(&self.take_unpremultiplied()))?; + writer.finish().map(|_| data) + } + + /// Returns a reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. + pub fn data(&self) -> &[PremulRgba8] { + &self.buf + } + + /// Returns a mutable reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. + pub fn data_mut(&mut self) -> &mut [PremulRgba8] { + &mut self.buf + } + + /// Returns a reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. Each pixel consists of four bytes in the order + /// `[r, g, b, a]`. + pub fn data_as_u8_slice(&self) -> &[u8] { + bytemuck::cast_slice(&self.buf) + } + + /// Returns a mutable reference to the underlying data as premultiplied RGBA8. + /// + /// The pixels are in row-major order. Each pixel consists of four bytes in the order + /// `[r, g, b, a]`. + pub fn data_as_u8_slice_mut(&mut self) -> &mut [u8] { + bytemuck::cast_slice_mut(&mut self.buf) + } + + /// Sample a pixel from the pixmap. + /// + /// The pixel data is [premultiplied RGBA8][PremulRgba8]. + #[inline(always)] + pub fn sample(&self, x: u16, y: u16) -> PremulRgba8 { + let idx = self.width as usize * y as usize + x as usize; + self.buf[idx] + } + + /// Sample a pixel from a custom-calculated index. This index should be calculated assuming that + /// the data is stored in row-major order. + #[inline(always)] + pub fn sample_idx(&self, idx: u32) -> PremulRgba8 { + self.buf[idx as usize] + } + + /// Set a pixel in the pixmap at the given coordinates. + /// + /// The pixel data should be [premultiplied RGBA8][PremulRgba8]. The coordinate system has + /// its origin at the top-left corner, with `x` increasing to the right and `y` increasing + /// downward. + #[inline(always)] + pub fn set_pixel(&mut self, x: u16, y: u16, pixel: PremulRgba8) { + let idx = self.width as usize * y as usize + x as usize; + self.buf[idx] = pixel; + } + + /// Consume the pixmap, returning the data as the underlying [`Vec`] of premultiplied RGBA8. + /// + /// The pixels are in row-major order. + pub fn take(self) -> Vec { + self.buf + } + + /// Consume the pixmap, returning the data as (unpremultiplied) RGBA8. + /// + /// Not fast, but useful for saving to PNG etc. + /// + /// The pixels are in row-major order. + pub fn take_unpremultiplied(self) -> Vec { + self.buf + .into_iter() + .map(|PremulRgba8 { r, g, b, a }| { + let alpha = 255.0 / f32::from(a); + if a != 0 { + #[expect(clippy::cast_possible_truncation, reason = "deliberate quantization")] + let unpremultiply = |component| (f32::from(component) * alpha + 0.5) as u8; + Rgba8 { + r: unpremultiply(r), + g: unpremultiply(g), + b: unpremultiply(b), + a, + } + } else { + Rgba8 { r, g, b, a } + } + }) + .collect() + } +} diff --git a/vendor/vello_common/src/probe.rs b/vendor/vello_common/src/probe.rs new file mode 100644 index 0000000000..5e48f1243c --- /dev/null +++ b/vendor/vello_common/src/probe.rs @@ -0,0 +1,419 @@ +// Copyright 2026 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Helpers for performing probing to verify the basic capabilities of the device we are +//! running on. + +use crate::color::{AlphaColor, palette::css}; +use crate::filter_effects::{EdgeMode, Filter, FilterPrimitive}; +use crate::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape}; +use crate::paint::{Image, ImageSource, PaintType}; +use crate::peniko::{ + BlendMode, ColorStop, ColorStops, Compose, Extend, Gradient, ImageQuality, ImageSampler, + LinearGradientPosition, Mix, +}; +use crate::pixmap::Pixmap; +use alloc::vec::Vec; + +const REFERENCE_RGBA: &[u8] = include_bytes!("../assets/probe.rgba"); + +const ELEMENTS_PER_ROW: usize = 3; +const ELEMENT_MARGIN: f64 = 1.0; + +const RECT_SIZE: f64 = 10.0; +const CIRCLE_RADIUS: f64 = 5.0; +const CIRCLE_CENTER_OFFSET_X: f64 = 1.5; +const IMAGE_SOURCE_SIZE: f64 = 5.0; +const PATH_TOLERANCE: f64 = 0.1; + +const ELEMENTS: [ProbeElement; 9] = [ + ProbeElement::SolidRect, + ProbeElement::AlphaBlending, + ProbeElement::Gradient, + ProbeElement::ImageNearest, + ProbeElement::Filter, + ProbeElement::ImageBilinear, + ProbeElement::OpacityLayer, + ProbeElement::Blending, + ProbeElement::Transformed, +]; +/// Per-channel absolute tolerance used when comparing probe pixels. +const CHANNEL_TOLERANCE: u8 = 3; + +/// Result of running the renderer probe. +#[derive(Debug, Clone)] +pub enum Probe { + /// The probe matched the bundled reference image. + Success, + /// The probe did not match the bundled reference image. + Error(ProbeResult), + /// Rendering the probe scene produces an error. + RenderError(E), +} + +/// Probe failure output. +#[derive(Debug, Clone)] +pub struct ProbeResult { + /// The expected probe image. + pub expected: ProbeImage, + /// The actual probe image. + pub actual: ProbeImage, +} + +/// A probe image stored as RGBA8 bytes. +#[derive(Debug, Clone)] +pub struct ProbeImage { + /// Width of the image in pixels. + pub width: u16, + /// Height of the image in pixels. + pub height: u16, + /// The image data as RGBA8 bytes. + pub data: Vec, +} + +impl Probe { + /// Returns `true` when the probe matched the bundled reference image. + pub fn is_success(&self) -> bool { + matches!(self, Self::Success) + } + + /// Construct a new probe result by inspecting the provided pixmap and comparing it + /// against the reference output. + pub fn from_actual(actual: Pixmap) -> Self { + let (width, height) = canvas_size(); + let expected = ProbeImage { + width, + height, + data: REFERENCE_RGBA.to_vec(), + }; + let actual = ProbeImage::from_pixmap(actual); + let matches_reference = expected.width == actual.width + && expected.height == actual.height + && expected.data.len() == actual.data.len() + && expected + .data + .chunks_exact(4) + .zip(actual.data.chunks_exact(4)) + .all(|(expected, actual)| { + pixels_within_tolerance(expected, actual, CHANNEL_TOLERANCE) + }); + + if matches_reference { + Self::Success + } else { + Self::Error(ProbeResult { expected, actual }) + } + } +} + +impl ProbeImage { + fn from_pixmap(pixmap: Pixmap) -> Self { + Self { + width: pixmap.width(), + height: pixmap.height(), + data: bytemuck::cast_slice(&pixmap.take_unpremultiplied()).to_vec(), + } + } +} + +/// API necessary to draw the probe scene. +pub trait ProbeRenderer { + fn set_transform(&mut self, transform: Affine); + fn set_paint(&mut self, paint: PaintType); + fn fill_path(&mut self, path: &BezPath); + fn fill_rect(&mut self, rect: &Rect); + fn push_layer(&mut self, blend_mode: Option, opacity: Option); + fn push_filter_layer(&mut self, filter: Filter); + fn pop_layer(&mut self); + fn set_paint_transform(&mut self, paint_transform: Affine); + fn reset_paint_transform(&mut self); +} + +#[derive(Clone, Copy, Debug)] +enum ProbeElement { + SolidRect, + Transformed, + AlphaBlending, + Gradient, + ImageNearest, + Filter, + ImageBilinear, + OpacityLayer, + Blending, +} + +#[derive(Clone, Copy, Debug)] +struct GridLayout { + columns: usize, + rows: usize, + cell_width: f64, + cell_height: f64, +} + +impl GridLayout { + fn from_elements(elements: &[ProbeElement]) -> Self { + let columns = ELEMENTS_PER_ROW.min(elements.len()); + let rows = elements.len().div_ceil(columns); + let (cell_width, cell_height) = elements + .iter() + .copied() + .map(ProbeElement::bounds) + .fold((0.0_f64, 0.0_f64), |(max_w, max_h), (w, h)| { + (max_w.max(w), max_h.max(h)) + }); + + Self { + columns, + rows, + cell_width, + cell_height, + } + } + + fn canvas_size(self) -> (u16, u16) { + let width = self.columns as f64 * self.cell_width + + self.columns.saturating_sub(1) as f64 * ELEMENT_MARGIN; + let height = self.rows as f64 * self.cell_height + + self.rows.saturating_sub(1) as f64 * ELEMENT_MARGIN; + (width.ceil() as u16, height.ceil() as u16) + } + + fn canvas_rect(self) -> Rect { + let (width, height) = self.canvas_size(); + Rect::new(0.0, 0.0, f64::from(width), f64::from(height)) + } + + fn cell_rect(self, index: usize) -> Rect { + let column = index % self.columns; + let row = index / self.columns; + let x0 = column as f64 * (self.cell_width + ELEMENT_MARGIN); + let y0 = row as f64 * (self.cell_height + ELEMENT_MARGIN); + Rect::new(x0, y0, x0 + self.cell_width, y0 + self.cell_height) + } +} + +impl ProbeElement { + fn bounds(self) -> (f64, f64) { + let (width, height) = match self { + Self::SolidRect + | Self::Gradient + | Self::ImageNearest + | Self::ImageBilinear + | Self::Filter + | Self::OpacityLayer => (RECT_SIZE, RECT_SIZE), + Self::Transformed => ( + RECT_SIZE * core::f64::consts::SQRT_2, + RECT_SIZE * core::f64::consts::SQRT_2, + ), + Self::AlphaBlending | Self::Blending => ( + CIRCLE_RADIUS * 2.0 + CIRCLE_CENTER_OFFSET_X * 2.0, + CIRCLE_RADIUS * 2.0, + ), + }; + (width + ELEMENT_MARGIN * 2.0, height + ELEMENT_MARGIN * 2.0) + } +} + +/// Return the canvas size of the shared probe scene. +pub fn canvas_size() -> (u16, u16) { + GridLayout::from_elements(&ELEMENTS).canvas_size() +} + +/// Return the pixmap that is referenced when drawing images in the scene. +pub fn probe_image_pixmap() -> Pixmap { + let mut pixmap = Pixmap::new(IMAGE_SOURCE_SIZE as u16, IMAGE_SOURCE_SIZE as u16); + for y in 0..pixmap.height() { + for x in 0..pixmap.width() { + pixmap.set_pixel( + x, + y, + AlphaColor::from_rgba8(255, 0, 0, 255) + .premultiply() + .to_rgba8(), + ); + } + } + pixmap.set_may_have_transparency(false); + pixmap +} + +fn image_paint(image: ImageSource, quality: ImageQuality) -> PaintType { + Image { + image, + sampler: ImageSampler { + x_extend: Extend::Pad, + y_extend: Extend::Pad, + quality, + alpha: 1.0, + }, + } + .into() +} + +/// Draw the full shared probe scene into a rendering context. +pub fn draw_scene(ctx: &mut T, image: ImageSource) { + let layout = GridLayout::from_elements(&ELEMENTS); + let image_nearest = image_paint(image.clone(), ImageQuality::Low); + let image_bilinear = image_paint(image, ImageQuality::Medium); + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(css::WHITE.into()); + ctx.fill_rect(&layout.canvas_rect()); + + for (index, element) in ELEMENTS.iter().copied().enumerate() { + draw_probe_element( + ctx, + layout.cell_rect(index), + element, + &image_nearest, + &image_bilinear, + ); + } +} + +fn pixels_within_tolerance(expected: &[u8], actual: &[u8], channel_tolerance: u8) -> bool { + if expected[3] == 0 && actual[3] == 0 { + return true; + } + + expected + .iter() + .zip(actual) + .all(|(expected, actual)| expected.abs_diff(*actual) <= channel_tolerance) +} + +fn draw_probe_element( + ctx: &mut impl ProbeRenderer, + cell: Rect, + element: ProbeElement, + image_nearest: &PaintType, + image_bilinear: &PaintType, +) { + match element { + ProbeElement::SolidRect => { + ctx.set_paint(css::BLUE.into()); + ctx.fill_rect(¢ered_rect(cell, RECT_SIZE, RECT_SIZE)); + } + ProbeElement::Transformed => { + draw_transformed_rect(ctx, centered_rect(cell, RECT_SIZE, RECT_SIZE)); + } + ProbeElement::AlphaBlending => { + let center = cell.center(); + ctx.set_paint(css::YELLOW.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x - CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + ctx.set_paint(css::GREEN.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x + CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + } + ProbeElement::Gradient => { + let rect = centered_rect(cell, RECT_SIZE, RECT_SIZE); + ctx.set_paint(linear_gradient(&rect).into()); + ctx.fill_rect(&rect); + } + ProbeElement::ImageNearest => draw_centered_padded_image(ctx, cell, image_nearest), + ProbeElement::Filter => draw_blurred_rect(ctx, centered_rect(cell, RECT_SIZE, RECT_SIZE)), + ProbeElement::ImageBilinear => draw_centered_padded_image(ctx, cell, image_bilinear), + ProbeElement::OpacityLayer => { + draw_opacity_layer_rect(ctx, centered_rect(cell, RECT_SIZE, RECT_SIZE)); + } + ProbeElement::Blending => draw_layered_difference_circles(ctx, cell), + } +} + +fn centered_rect(cell: Rect, width: f64, height: f64) -> Rect { + let center = cell.center(); + Rect::new( + center.x - width * 0.5, + center.y - height * 0.5, + center.x + width * 0.5, + center.y + height * 0.5, + ) +} + +fn draw_centered_padded_image(ctx: &mut impl ProbeRenderer, cell: Rect, image_paint: &PaintType) { + let dst_rect = centered_rect(cell, RECT_SIZE, RECT_SIZE); + let image_origin = ( + dst_rect.x0 + (RECT_SIZE - IMAGE_SOURCE_SIZE) * 0.5, + dst_rect.y0 + (RECT_SIZE - IMAGE_SOURCE_SIZE) * 0.5, + ); + ctx.set_paint(image_paint.clone()); + ctx.set_paint_transform(Affine::translate(image_origin)); + ctx.fill_rect(&dst_rect); + ctx.reset_paint_transform(); +} + +fn draw_transformed_rect(ctx: &mut impl ProbeRenderer, rect: Rect) { + let center = rect.center(); + ctx.set_transform( + Affine::translate((center.x, center.y)) + * Affine::rotate(core::f64::consts::FRAC_PI_4) + * Affine::translate((-center.x, -center.y)), + ); + ctx.set_paint(css::BLUE.into()); + ctx.fill_rect(&rect); + ctx.set_transform(Affine::IDENTITY); +} + +fn draw_blurred_rect(ctx: &mut impl ProbeRenderer, rect: Rect) { + let blur = Filter::from_primitive(FilterPrimitive::GaussianBlur { + std_deviation: 0.5, + edge_mode: EdgeMode::None, + }); + ctx.push_filter_layer(blur); + ctx.set_paint(css::REBECCA_PURPLE.into()); + ctx.fill_rect(&rect); + ctx.pop_layer(); +} + +fn draw_opacity_layer_rect(ctx: &mut impl ProbeRenderer, rect: Rect) { + ctx.push_layer(None, Some(0.5)); + ctx.set_paint(css::ORANGE_RED.into()); + ctx.fill_rect(&rect); + ctx.pop_layer(); +} + +fn draw_layered_difference_circles(ctx: &mut impl ProbeRenderer, cell: Rect) { + let center = cell.center(); + + ctx.push_layer(None, None); + ctx.set_paint(css::YELLOW.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x - CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + + ctx.push_layer( + Some(BlendMode::new(Mix::Difference, Compose::SrcOver)), + None, + ); + ctx.set_paint(css::GREEN.with_alpha(0.5).into()); + ctx.fill_path( + &Circle::new((center.x + CIRCLE_CENTER_OFFSET_X, center.y), CIRCLE_RADIUS) + .to_path(PATH_TOLERANCE), + ); + ctx.pop_layer(); + ctx.pop_layer(); +} + +fn linear_gradient(rect: &Rect) -> Gradient { + Gradient { + kind: LinearGradientPosition { + start: Point::new(rect.x0, rect.y0), + end: Point::new(rect.x1, rect.y0), + } + .into(), + stops: ColorStops::from( + [ + ColorStop::from((0.0, css::BLUE)), + ColorStop::from((1.0, css::RED)), + ] + .as_slice(), + ), + extend: Extend::Pad, + ..Default::default() + } +} diff --git a/vendor/vello_common/src/rect.rs b/vendor/vello_common/src/rect.rs new file mode 100644 index 0000000000..55f98b7160 --- /dev/null +++ b/vendor/vello_common/src/rect.rs @@ -0,0 +1,180 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Fast pixel-aligned rectangle rendering directly into strips. + +use crate::kurbo::Rect; +#[cfg(not(feature = "std"))] +use crate::kurbo::common::FloatFuncs as _; +use crate::strip::Strip; +use crate::tile::Tile; +use alloc::vec::Vec; +use fearless_simd::*; + +/// Render a pixel-aligned rectangle directly into strips. +/// +/// This bypasses the full path processing pipeline (flatten → tiles → strips) +/// by directly creating strip coverage data for the rectangle. +/// +/// The rect bounds should already be clamped to the viewport. +pub fn render(level: Level, rect: Rect, strip_buf: &mut Vec, alpha_buf: &mut Vec) { + dispatch!(level, simd => render_impl(simd, rect, strip_buf, alpha_buf)); +} + +/// Generates strip data for an axis-aligned rectangle. +/// +/// # Strip layout strategy +/// +/// Tile rows are classified into two kinds: +/// +/// - **Edge rows** (top/bottom of rect): the rect boundary crosses partway +/// through the tile vertically, so individual pixels need per-cell alpha. +/// We emit a *single wide strip* spanning all tile columns, with alpha = +/// `x_alpha` * `y_alpha` (so the intersection of the alpha mask in each direction). +/// +/// - **Interior rows**: every pixel in the tile has full vertical coverage, +/// so we only need to handle the left and right partial-column edges. +/// We emit a **left edge strip** (with its x-alpha mask) and, when the rect +/// spans more than one tile column, a **right edge strip** with `fill_gap = +/// true` so the renderer fills solid 0xFF between them. +/// +/// The x-alpha masks for the left/right edge tiles are y-independent, so they +/// are precomputed once and reused across all interior rows. +fn render_impl(s: S, rect: Rect, strip_buf: &mut Vec, alpha_buf: &mut Vec) { + if rect.is_zero_area() { + return; + } + + let rect_x0 = rect.x0 as f32; + let rect_y0 = rect.y0 as f32; + let rect_x1 = rect.x1 as f32; + let rect_y1 = rect.y1 as f32; + + // Integer pixel bounds. + let px_x0 = rect_x0.floor() as u16; + let px_y0 = rect_y0.floor() as u16; + let px_y1 = rect_y1.ceil() as u16; + + let left_tile_x = (px_x0 / Tile::WIDTH) * Tile::WIDTH; + // Inclusive, so don't use `ceil` here but just `rect_x1` directly. + let right_tile_x = (rect_x1 as u16 / Tile::WIDTH) * Tile::WIDTH; + + let y0 = (px_y0 / Tile::HEIGHT) * Tile::HEIGHT; + // Note: y1 is exclusive, but it's gonna break for the very last tile if we have a height of u16::MAX. + let y1 = (px_y1.saturating_add(Tile::HEIGHT - 1) / Tile::HEIGHT) * Tile::HEIGHT; + // Include one tile past the right edge so the right-edge tile column is + // covered by the edge-row wide-strip loop. + let x_end = right_tile_x.saturating_add(Tile::WIDTH); + + if x_end <= left_tile_x || y1 <= y0 { + return; + } + + let tile_start_y = y0 / Tile::HEIGHT; + let tile_end_y = y1 / Tile::HEIGHT; + + // A right strip is only needed when the rect spans more than one tile column. + let needs_right_strip = right_tile_x > left_tile_x; + + let left_x_cov = coverage(left_tile_x, rect_x0, rect_x1); + let right_x_cov = coverage(right_tile_x, rect_x0, rect_x1); + let left_x_mask = alpha_mask_from_x_coverage(s, &left_x_cov); + let right_x_mask = alpha_mask_from_x_coverage(s, &right_x_cov); + + for tile_y in tile_start_y..tile_end_y { + let strip_y = tile_y * Tile::HEIGHT; + let strip_y_f = strip_y as f32; + let strip_y_end_f = strip_y as f32 + Tile::HEIGHT as f32; + + // A row is an "edge" if the rect's top or bottom boundary falls + // *inside* it (i.e. partial vertical coverage). + let is_top_edge = strip_y_f < rect_y0 && rect_y0 < strip_y_end_f; + let is_bottom_edge = strip_y_f < rect_y1 && rect_y1 < strip_y_end_f; + + if is_top_edge || is_bottom_edge { + let alpha_start = alpha_buf.len() as u32; + + let y_cov = coverage(strip_y, rect_y0, rect_y1); + let mut col = left_tile_x; + // TODO: Can this result in an infinite loop in case x_end == u16::MAX? + while col + Tile::WIDTH <= x_end { + // TODO: We could optimize this so this is only computed for the left-most and right-most + // tile of the edge, all intermediate tiles have full horizontal coverage. + let x_cov = coverage(col, rect_x0, rect_x1); + let combined = combined_tile_alpha(s, &x_cov, &y_cov); + alpha_buf.extend_from_slice(combined.as_slice()); + col += Tile::WIDTH; + } + + strip_buf.push(Strip::new(left_tile_x, strip_y, alpha_start, false)); + } else { + let alpha_start = alpha_buf.len() as u32; + alpha_buf.extend_from_slice(left_x_mask.as_slice()); + strip_buf.push(Strip::new(left_tile_x, strip_y, alpha_start, false)); + + if needs_right_strip { + // `fill_gap = true` tells the renderer to fill solid 0xFF + // between the previous strip's end and this strip's start. + let alpha_start = alpha_buf.len() as u32; + alpha_buf.extend_from_slice(right_x_mask.as_slice()); + strip_buf.push(Strip::new(right_tile_x, strip_y, alpha_start, true)); + } + } + } + + // Sentinel strip: marks the end of the strip list for this shape. + let last_strip_y = (tile_end_y - 1) * Tile::HEIGHT; + strip_buf.push(Strip::new( + u16::MAX, + last_strip_y, + alpha_buf.len() as u32, + false, + )); +} + +/// Compute fractional pixel coverage for `N` consecutive pixels starting at `start`. +#[inline(always)] +fn coverage(start: u16, rect_lo: f32, rect_hi: f32) -> [f32; N] { + let mut cov = [0.0_f32; N]; + + #[allow(clippy::needless_range_loop, reason = "better clarity")] + for i in 0..N { + let px = (start as usize + i) as f32; + cov[i] = (rect_hi.min(px + 1.0) - rect_lo.max(px)).clamp(0.0, 1.0); + } + cov +} + +/// Build an alpha mask for the 4x4 tile from the given horizontal coverages, +/// splatting them across the other dimension. +#[inline(always)] +fn alpha_mask_from_x_coverage(s: S, cov: &[f32; Tile::WIDTH as usize]) -> u8x16 { + let mut buf = [0_u8; 16]; + + #[allow(clippy::needless_range_loop, reason = "better clarity")] + for col in 0..Tile::WIDTH as usize { + let alpha = (cov[col] * 255.0 + 0.5) as u8; + let base = col * Tile::HEIGHT as usize; + buf[base..base + Tile::HEIGHT as usize].fill(alpha); + } + + u8x16::from_slice(s, &buf) +} + +/// Compute the alphas for a single 4x4 tile, taking horizontal as well as vertical coverage +/// of the rectangle into account. +#[inline(always)] +fn combined_tile_alpha( + s: S, + x_cov: &[f32; Tile::WIDTH as usize], + y_cov: &[f32; Tile::HEIGHT as usize], +) -> u8x16 { + let mut buf = [0_u8; 16]; + for (col, xc) in x_cov.iter().copied().enumerate() { + for (row, yc) in y_cov.iter().copied().enumerate() { + buf[col * Tile::HEIGHT as usize + row] = (xc * yc * 255.0 + 0.5) as u8; + } + } + + u8x16::from_slice(s, &buf) +} diff --git a/vendor/vello_common/src/render_graph.rs b/vendor/vello_common/src/render_graph.rs new file mode 100644 index 0000000000..4ddb871ec5 --- /dev/null +++ b/vendor/vello_common/src/render_graph.rs @@ -0,0 +1,616 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Render graph for multi-pass rendering with filter effects. +//! +//! This module provides a directed acyclic graph (DAG) representation of the rendering pipeline, +//! enabling efficient multi-pass rendering when layers have filter effects applied. +//! +//! # Overview +//! +//! The render graph tracks dependencies between rendering operations (nodes) and ensures they +//! execute in the correct order. Each node represents either: +//! - The root (backdrop) layer that forms the base for compositing +//! - A filtered layer that renders geometry, applies effects, and blends the result +//! +//! # Key Features +//! +//! - **Incremental execution order**: The graph builds its execution order as layers are popped +//! during scene encoding, avoiding the need for a full topological sort at render time. +//! - **Data dependencies**: Edges represent dependencies where one layer's output is consumed +//! as input to another operation (e.g., a filter reading from a previous layer's buffer). +//! - **Efficient traversal**: The pre-computed execution order allows O(1) iteration over nodes +//! in dependency order, with children always processed before their parents. +//! +//! # Usage Pattern +//! +//! 1. During scene encoding, nodes are added via [`RenderGraph::add_node`] as layers are created +//! 2. When layers with dependencies are popped, edges are added via [`RenderGraph::add_edge`] +//! 3. As each layer completes, it's recorded in execution order via [`RenderGraph::record_node_for_execution`] +//! 4. At render time, iterate nodes in order using [`RenderGraph::execution_order`] +//! +//! # Example Structure +//! +//! ```text +//! RootLayer (id=0) ← backdrop +//! │ +//! └─→ FilterLayer (id=1, blur filter) +//! │ +//! └─→ FilterLayer (id=2, drop shadow filter) +//! +//! Execution order: [2, 1, 0] (deepest children first, then parents) +//! ``` +//! +//! In this example, `FilterLayer 2` (drop shadow) is the most deeply nested child, so it +//! executes first. Then `FilterLayer 1` (blur) executes and can reference `FilterLayer 2`'s output +//! if needed. Finally, the `RootLayer` composites all filtered results into the final image. +//! +//! # Future Improvements +//! +//! TODO: Consider using `render_graph` for `FilterGraph` execution for filters. The render graph +//! infrastructure could potentially be extended to handle the internal DAG structure within +//! individual filter effects, not just layer-to-layer dependencies. + +use crate::coarse::WideTilesBbox; +use crate::filter_effects::Filter; +use crate::kurbo::Affine; +use alloc::vec; +use alloc::vec::Vec; +use hashbrown::HashMap; + +/// A render graph containing nodes and edges representing the rendering pipeline. +/// +/// This graph tracks layers with filter effects and their dependencies, enabling +/// multi-pass rendering where filter outputs can be used as inputs to subsequent operations. +#[derive(Debug, Clone)] +pub struct RenderGraph { + /// All render operations (layers) in the graph. + pub nodes: Vec, + /// Dependencies between nodes, defining execution order. + pub edges: Vec, + /// Counter for generating unique node IDs. + next_node_id: NodeId, + /// Pre-computed execution order collected during `pop_layer` calls. + /// This is built incrementally as layers are popped (children before parents), + /// avoiding the need for topological sort at render time. + node_execution_order: Vec, + /// The root layer node ID, tracked separately since it's never popped. + /// This is appended to the execution order when iterating. + root_node: Option, + /// Flag indicating whether the graph contains any filter layers. + /// Set to true when a `FilterLayer` node is added. + has_filters: bool, +} + +/// The type of dependency between nodes. +#[derive(Debug, Clone)] +pub enum DependencyKind { + /// Sequential execution: the `from` node must execute before the `to` node. + /// + /// The `to` node consumes output from the `from` node via the specified layer. + /// For example, a parent filter reads from a child layer buffer produced by a previous operation. + Sequential { + /// The layer that connects the two nodes, whose output flows from `from` to `to`. + layer_id: LayerId, + }, +} + +impl Default for RenderGraph { + fn default() -> Self { + Self::new() + } +} + +impl RenderGraph { + /// Creates a new empty render graph. + /// + /// The graph starts with no nodes or edges. Nodes are added via [`add_node`](Self::add_node) + /// as layers are created during scene encoding. + pub fn new() -> Self { + Self { + nodes: Vec::new(), + edges: Vec::new(), + next_node_id: 0, + node_execution_order: Vec::new(), + root_node: None, + has_filters: false, + } + } + + /// Clears the render graph state while preserving allocated capacity. + /// + /// This resets the graph to an empty state (equivalent to [`new`](Self::new)) + /// but reuses the existing memory allocations, avoiding the need to reallocate + /// when building a new scene. + /// + /// After calling `clear()`, the graph will have no nodes or edges, and counters + /// will be reset to their initial state. + pub fn clear(&mut self) { + self.nodes.clear(); + self.edges.clear(); + self.next_node_id = 0; + self.node_execution_order.clear(); + self.root_node = None; + self.has_filters = false; + } + + /// Add a node to the graph and return its ID. + /// + /// This is called during scene encoding when a new layer is created. The returned ID + /// should be stored with the layer to establish edges later when dependencies are known. + pub fn add_node(&mut self, kind: RenderNodeKind) -> NodeId { + let id = self.next_node_id; + self.next_node_id += 1; + + // Track root node separately since it's never popped and executes last + if matches!(kind, RenderNodeKind::RootLayer { .. }) { + self.root_node = Some(id); + } + + // Track if we have any filters to avoid scanning nodes later + if matches!(kind, RenderNodeKind::FilterLayer { .. }) { + self.has_filters = true; + } + + self.nodes.push(RenderNode { id, kind }); + id + } + + /// Add an edge between two nodes, establishing a dependency relationship. + /// + /// This is called when a layer with dependencies is popped during scene encoding. + /// The edge ensures that `from` node executes before `to` node, typically because + /// `to` needs to read data produced by `from` (e.g., a filter reading from a layer buffer). + pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: DependencyKind) { + self.edges.push(RenderEdge { from, to, kind }); + } + + /// Record a node as ready for execution in topological order. + /// + /// This is called from `pop_layer` to incrementally build the execution order + /// as layers are popped. Since layers are popped in a depth-first manner (children + /// before parents), this naturally builds the correct dependency order without + /// requiring a separate topological sort at render time. + /// + /// # Example + /// + /// For nested layers A → B → C (where C is the deepest child): + /// - C is popped first → recorded first + /// - B is popped second → recorded second + /// - A is popped last → recorded last + /// + /// This gives execution order [C, B, A], which respects dependencies. + pub fn record_node_for_execution(&mut self, node_id: NodeId) { + self.node_execution_order.push(node_id); + } + + /// Check if the graph contains any filter passes. + /// + /// Returns `true` if any layers have filter effects that need processing. + /// This is useful for determining whether to use the multi-pass rendering + /// pipeline (with filter support) or a simpler single-pass approach. + /// + /// This is set when filter nodes are added. + pub fn has_filters(&self) -> bool { + self.has_filters + } + + /// Get an iterator over nodes in execution order. + /// + /// Returns filter layers followed by the root layer, in the order they should be executed. + /// This order is built incrementally during `pop_layer` calls (children before parents), + /// avoiding the need for topological sort at render time. Returns an iterator to avoid allocation. + pub fn execution_order(&self) -> impl Iterator + '_ { + self.node_execution_order + .iter() + .copied() + .chain(self.root_node) + } + + /// Topologically sort nodes based on their dependencies using Kahn's algorithm. + /// + /// Returns nodes in execution order, ensuring all dependencies are satisfied before + /// each node is processed. This implements a standard topological sort with cycle detection. + /// + /// # Performance + /// + /// This is primarily used for validation and debugging. For normal rendering, + /// prefer [`execution_order`](Self::execution_order) which is pre-computed during + /// scene encoding and avoids the O(V+E) sorting overhead. + /// + /// # Panics + /// + /// Panics if the graph contains a cycle, which should never happen in a valid render graph. + #[allow( + dead_code, + reason = "we don't use currently topological sort but will in the future" + )] + pub fn topological_sort(&self) -> Vec { + // Track how many incoming edges each node has (dependencies it's waiting for) + let mut in_degree = vec![0; self.nodes.len()]; + + // Map each node to its dependents (nodes that depend on it) + let mut adj_list: HashMap> = HashMap::new(); + + // Build adjacency list and count incoming edges for each node + for edge in &self.edges { + adj_list.entry(edge.from).or_default().push(edge.to); + in_degree[edge.to] += 1; + } + + // Start with nodes that have no dependencies (in-degree = 0) + // These are safe to execute immediately + let mut queue: Vec = (0..self.nodes.len()) + .filter(|&i| in_degree[i] == 0) + .collect(); + + let mut result = Vec::new(); + + // Process nodes in dependency order (Kahn's algorithm) + while let Some(node_id) = queue.pop() { + result.push(node_id); + + // For each node that depends on this one, mark this dependency as satisfied + if let Some(neighbors) = adj_list.get(&node_id) { + for &neighbor in neighbors { + in_degree[neighbor] -= 1; + // If all dependencies are now satisfied, this node is ready to execute + if in_degree[neighbor] == 0 { + queue.push(neighbor); + } + } + } + } + + // If we didn't process all nodes, there's a cycle (which should never happen) + assert_eq!( + result.len(), + self.nodes.len(), + "Cycle detected in render graph" + ); + result + } +} + +/// A node in the render graph representing a single render operation. +#[derive(Debug, Clone)] +pub struct RenderNode { + /// Unique identifier for this node. + pub id: NodeId, + /// The type of render operation this node performs. + pub kind: RenderNodeKind, +} + +impl RenderNode { + /// Returns `true` if this node has an empty (zero-area) bounding box. + pub fn is_empty(&self) -> bool { + match &self.kind { + RenderNodeKind::RootLayer { wtile_bbox, .. } => wtile_bbox.is_empty(), + RenderNodeKind::FilterLayer { wtile_bbox, .. } => wtile_bbox.is_empty(), + } + } +} + +/// An edge representing a dependency between two nodes. +#[derive(Debug, Clone)] +pub struct RenderEdge { + /// The source node that must complete first. + pub from: NodeId, + /// The destination node that depends on the source. + pub to: NodeId, + /// The type of dependency relationship. + pub kind: DependencyKind, +} + +/// The type of operation a render node performs. +/// +/// Each variant represents a different kind of rendering pass in the pipeline. +/// Nodes are executed in dependency order to ensure correct compositing and filtering. +#[derive(Debug, Clone)] +pub enum RenderNodeKind { + /// The root (backdrop) layer that serves as the base for compositing. + /// + /// This layer is always present and is rendered last, after all filter layers have + /// been processed. It contains all non-filtered geometry and serves as the final + /// compositing target. + RootLayer { + /// ID of the root layer. + layer_id: LayerId, + /// Bounding box in wide tile coordinates covered by this layer. + wtile_bbox: WideTilesBbox, + }, + /// A layer with filter effects applied. + /// + /// This combines multiple passes: render geometry → apply filter → blend result. + /// Layers are added to the render graph only if they have filters. The filter + /// output may be consumed by other filter layers or composited into the root layer. + FilterLayer { + /// ID of this filtered layer. + layer_id: LayerId, + /// The filter effect to apply. + filter: Filter, + /// Bounding box in wide tile coordinates containing geometry for this layer. + wtile_bbox: WideTilesBbox, + /// Transform that was active when the layer was created. + /// Used to scale filter parameters based on the current scale/zoom level. + transform: Affine, + }, +} + +/// Unique identifier for a layer in the rendering system. +/// +/// Layer IDs are assigned by the encoding system and used to track layer buffers +/// across multiple render passes. A layer may or may not have a corresponding node +/// in the render graph (only layers with filters create graph nodes). +pub type LayerId = u32; + +/// Unique identifier for a node in the render graph. +/// +/// Node IDs are assigned sequentially as nodes are added during scene encoding. +/// They are used to reference nodes when adding edges and during execution order traversal. +pub type NodeId = usize; + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::AlphaColor; + + #[test] + fn new_creates_empty_graph() { + let graph = RenderGraph::new(); + assert_eq!(graph.nodes.len(), 0); + assert_eq!(graph.edges.len(), 0); + assert!(!graph.has_filters()); + assert!(graph.root_node.is_none()); + } + + #[test] + fn clear_resets_to_empty_state() { + let mut graph = RenderGraph::new(); + graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + graph.record_node_for_execution(1); + + graph.clear(); + + assert_eq!(graph.nodes.len(), 0); + assert_eq!(graph.edges.len(), 0); + assert_eq!(graph.next_node_id, 0); + assert!(graph.root_node.is_none()); + assert!(!graph.has_filters()); + assert_eq!(graph.execution_order().count(), 0); + } + + #[test] + fn clear_preserves_capacity() { + let mut graph = RenderGraph::new(); + let from = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + let to = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 1, + wtile_bbox: dummy_bbox(), + }); + graph.add_edge(from, to, DependencyKind::Sequential { layer_id: 1 }); + + let nodes_capacity = graph.nodes.capacity(); + let edges_capacity = graph.edges.capacity(); + + graph.clear(); + + assert!(graph.nodes.capacity() >= nodes_capacity); + assert!(graph.edges.capacity() >= edges_capacity); + } + + #[test] + fn add_node_returns_sequential_ids() { + let mut graph = RenderGraph::new(); + let id0 = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + let id1 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + + assert_eq!(id0, 0); + assert_eq!(id1, 1); + } + + #[test] + fn add_node_tracks_root() { + let mut graph = RenderGraph::new(); + let root_id = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + assert_eq!(graph.root_node, Some(root_id)); + } + + #[test] + fn add_node_filter_sets_has_filters() { + let mut graph = RenderGraph::new(); + + graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + + assert!(graph.has_filters()); + } + + #[test] + fn add_node_root_does_not_set_has_filters() { + let mut graph = RenderGraph::new(); + + graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + assert!(!graph.has_filters()); + } + + #[test] + fn add_edge_creates_dependency() { + let mut graph = RenderGraph::new(); + let from = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let to = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + graph.add_edge(from, to, DependencyKind::Sequential { layer_id: 1 }); + + assert_eq!(graph.edges.len(), 1); + assert_eq!(graph.edges[0].from, from); + assert_eq!(graph.edges[0].to, to); + } + + #[test] + fn add_edge_multiple_from_same_node() { + let mut graph = RenderGraph::new(); + let from = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let to1 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 2, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let to2 = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + graph.add_edge(from, to1, DependencyKind::Sequential { layer_id: 1 }); + graph.add_edge(from, to2, DependencyKind::Sequential { layer_id: 1 }); + + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn execution_order_includes_recorded_nodes() { + let mut graph = RenderGraph::new(); + let filter = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + + graph.record_node_for_execution(filter); + + let order: Vec<_> = graph.execution_order().collect(); + assert_eq!(order, vec![filter]); + } + + #[test] + fn execution_order_includes_only_root_when_present() { + let mut graph = RenderGraph::new(); + let root = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + let order: Vec<_> = graph.execution_order().collect(); + assert_eq!(order, vec![root]); + } + + #[test] + fn execution_order_root_comes_last() { + let mut graph = RenderGraph::new(); + let filter1 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let filter2 = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 2, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let root = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + graph.record_node_for_execution(filter2); + graph.record_node_for_execution(filter1); + + let order: Vec<_> = graph.execution_order().collect(); + assert_eq!(order, vec![filter2, filter1, root]); + } + + #[test] + fn topological_sort_diamond_dependency() { + let mut graph = RenderGraph::new(); + let bottom = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 3, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let left = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 1, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let right = graph.add_node(RenderNodeKind::FilterLayer { + layer_id: 2, + filter: dummy_filter(), + wtile_bbox: dummy_bbox(), + transform: Affine::IDENTITY, + }); + let top = graph.add_node(RenderNodeKind::RootLayer { + layer_id: 0, + wtile_bbox: dummy_bbox(), + }); + + // Diamond: bottom -> left -> top, bottom -> right -> top + graph.add_edge(bottom, left, DependencyKind::Sequential { layer_id: 3 }); + graph.add_edge(bottom, right, DependencyKind::Sequential { layer_id: 3 }); + graph.add_edge(left, top, DependencyKind::Sequential { layer_id: 1 }); + graph.add_edge(right, top, DependencyKind::Sequential { layer_id: 2 }); + + let sorted = graph.topological_sort(); + assert_eq!(sorted, vec![bottom, right, left, top]); + } + + fn dummy_filter() -> Filter { + Filter::from_primitive(crate::filter_effects::FilterPrimitive::Flood { + color: AlphaColor::from_rgba8(0, 0, 0, 255), + }) + } + + fn dummy_bbox() -> WideTilesBbox { + WideTilesBbox::ZERO + } +} diff --git a/vendor/vello_common/src/render_state.rs b/vendor/vello_common/src/render_state.rs new file mode 100644 index 0000000000..11e9056db7 --- /dev/null +++ b/vendor/vello_common/src/render_state.rs @@ -0,0 +1,56 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shared render state. + +use crate::kurbo::{Affine, Cap, Join, Stroke}; +use crate::paint::{PaintType, Tint}; +use crate::peniko::color::palette::css::BLACK; +use crate::peniko::{BlendMode, Compose, Fill, Mix}; + +/// A render state which contains the style properties for path rendering and +/// the current transform. +#[derive(Debug, Clone)] +pub struct RenderState { + /// The paint type (solid color, gradient, or image). + pub paint: PaintType, + /// Transform applied to the paint coordinates. + pub paint_transform: Affine, + /// Stroke style for path stroking operations. + pub stroke: Stroke, + /// Transform applied to geometry. + pub transform: Affine, + /// Fill rule for path filling operations. + pub fill_rule: Fill, + /// Blend mode for compositing. + pub blend_mode: BlendMode, + /// The tint for image painting. + pub tint: Option, +} + +impl Default for RenderState { + fn default() -> Self { + Self { + paint: BLACK.into(), + paint_transform: Affine::IDENTITY, + stroke: Stroke { + width: 1.0, + join: Join::Bevel, + start_cap: Cap::Butt, + end_cap: Cap::Butt, + ..Default::default() + }, + transform: Affine::IDENTITY, + fill_rule: Fill::NonZero, + blend_mode: BlendMode::new(Mix::Normal, Compose::SrcOver), + tint: None, + } + } +} + +impl RenderState { + /// Reset to default state. + pub fn reset(&mut self) { + *self = Self::default(); + } +} diff --git a/vendor/vello_common/src/simd.rs b/vendor/vello_common/src/simd.rs new file mode 100644 index 0000000000..d267fc33e4 --- /dev/null +++ b/vendor/vello_common/src/simd.rs @@ -0,0 +1,76 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! A number of SIMD extension traits. + +use fearless_simd::*; + +/// Splatting every 4th element in the vector, used for splatting the alpha value of +/// a color to all lanes. +pub trait Splat4thExt { + /// Splat every 4th element of the vector. + fn splat_4th(self) -> Self; +} + +impl Splat4thExt for f32x4 { + #[inline(always)] + fn splat_4th(self) -> Self { + // TODO: Explore whether it's just faster to manually access the 4th element and splat it. + let zip1 = self.zip_high(self); + zip1.zip_high(zip1) + } +} + +impl Splat4thExt for f32x8 { + #[inline(always)] + fn splat_4th(self) -> Self { + let (mut p1, mut p2) = self.simd.split_f32x8(self); + p1 = p1.splat_4th(); + p2 = p2.splat_4th(); + + self.simd.combine_f32x4(p1, p2) + } +} + +impl Splat4thExt for f32x16 { + #[inline(always)] + fn splat_4th(self) -> Self { + let (mut p1, mut p2) = self.simd.split_f32x16(self); + p1 = p1.splat_4th(); + p2 = p2.splat_4th(); + + self.simd.combine_f32x8(p1, p2) + } +} + +impl Splat4thExt for u8x16 { + #[inline(always)] + fn splat_4th(self) -> Self { + // TODO: SIMDify + [ + self[3], self[3], self[3], self[3], self[7], self[7], self[7], self[7], self[11], + self[11], self[11], self[11], self[15], self[15], self[15], self[15], + ] + .simd_into(self.simd) + } +} + +impl Splat4thExt for u8x32 { + #[inline(always)] + fn splat_4th(self) -> Self { + let (mut p1, mut p2) = self.simd.split_u8x32(self); + p1 = p1.splat_4th(); + p2 = p2.splat_4th(); + + self.simd.combine_u8x16(p1, p2) + } +} + +/// Splat each single element in the vector to 4 lanes. +#[inline(always)] +pub fn element_wise_splat(simd: S, input: f32x4) -> f32x16 { + simd.combine_f32x8( + simd.combine_f32x4(f32x4::splat(simd, input[0]), f32x4::splat(simd, input[1])), + simd.combine_f32x4(f32x4::splat(simd, input[2]), f32x4::splat(simd, input[3])), + ) +} diff --git a/vendor/vello_common/src/strip.rs b/vendor/vello_common/src/strip.rs new file mode 100644 index 0000000000..315f1972ee --- /dev/null +++ b/vendor/vello_common/src/strip.rs @@ -0,0 +1,535 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Rendering strips. + +use crate::flatten::Line; +use crate::peniko::Fill; +use crate::tile::{Tile, Tiles}; +use crate::util::f32_to_u8; +use alloc::vec::Vec; +use fearless_simd::*; + +/// A strip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Strip { + /// The x coordinate of the strip, in user coordinates. + pub x: u16, + /// The y coordinate of the strip, in user coordinates. + pub y: u16, + /// Packed alpha index and fill gap flag. + /// + /// Bit layout (u32): + /// - bit 31: `fill_gap` (See `Strip::fill_gap()`). + /// - bits 0..=30: `alpha_idx` (See `Strip::alpha_idx()`). + packed_alpha_idx_fill_gap: u32, +} + +impl Strip { + /// The bit mask for `fill_gap` packed into `packed_alpha_idx_fill_gap`. + const FILL_GAP_MASK: u32 = 1 << 31; + + /// Creates a new strip. + pub fn new(x: u16, y: u16, alpha_idx: u32, fill_gap: bool) -> Self { + // Ensure `alpha_idx` does not collide with the fill flag bit. + assert!( + alpha_idx & Self::FILL_GAP_MASK == 0, + "`alpha_idx` too large" + ); + let fill_gap = u32::from(fill_gap) << 31; + Self { + x, + y, + packed_alpha_idx_fill_gap: alpha_idx | fill_gap, + } + } + + /// Return whether the strip is a sentinel strip. + pub fn is_sentinel(&self) -> bool { + self.x == u16::MAX + } + + /// Return the y coordinate of the strip, in strip units. + pub fn strip_y(&self) -> u16 { + self.y / Tile::HEIGHT + } + + /// Returns the alpha index. + #[inline(always)] + pub fn alpha_idx(&self) -> u32 { + self.packed_alpha_idx_fill_gap & !Self::FILL_GAP_MASK + } + + /// Sets the alpha index. + /// + /// Note that the largest value that can be stored in the alpha index is `u32::MAX << 1`, as the + /// highest bit is reserved for `fill_gap`. + #[inline(always)] + pub fn set_alpha_idx(&mut self, alpha_idx: u32) { + // Ensure `alpha_idx` does not collide with the fill flag bit. + assert!( + alpha_idx & Self::FILL_GAP_MASK == 0, + "`alpha_idx` too large" + ); + let fill_gap = self.packed_alpha_idx_fill_gap & Self::FILL_GAP_MASK; + self.packed_alpha_idx_fill_gap = alpha_idx | fill_gap; + } + + /// Returns whether the gap that lies between this strip and the previous in the same row should be filled. + #[inline(always)] + pub fn fill_gap(&self) -> bool { + (self.packed_alpha_idx_fill_gap & Self::FILL_GAP_MASK) != 0 + } + + /// Sets whether the gap that lies between this strip and the previous in the same row should be filled. + #[inline(always)] + pub fn set_fill_gap(&mut self, fill: bool) { + let fill = u32::from(fill) << 31; + self.packed_alpha_idx_fill_gap = + (self.packed_alpha_idx_fill_gap & !Self::FILL_GAP_MASK) | fill; + } + + /// When early culling is active, geometry fully to the left of the viewport creates no tiles. + /// However, if that geometry has a non-zero winding (e.g. a large shape surrounding the + /// viewport), then we must output strips for those fills. + /// + /// We reconstruct this "background" fill using `row_windings` (the winding at x=0) to emit solid + /// strips for: + /// 1. All rows vertically above the first visible tile. + /// 2. 'Captive' rows between two tile-containing rows. + /// 3. All rows vertically below the last visible tile. + #[inline(always)] + fn emit_culled_background( + start: u16, + end: u16, + strips: &mut Vec, + alphas: &mut Vec, + windings: &crate::tile::CulledWindings, + mut should_fill: F, + ) where + F: FnMut(i32) -> bool, + { + windings.for_active_rows_in_range(start as usize, end as usize, |row| { + if should_fill(windings.coarse[row] as i32) { + let y_pos = row as u16 * Tile::HEIGHT; + strips.push(Self::new(0, y_pos, alphas.len() as u32, false)); + alphas.extend([255_u8; Tile::HEIGHT as usize * Tile::WIDTH as usize]); + // TODO: Clamp to the scene width instead of u16::MAX; in the future there might + // not be clamping on the x. + strips.push(Self::new(u16::MAX, y_pos, alphas.len() as u32, true)); + } + }); + } +} + +/// Render the tiles stored in `tiles` into the strip and alpha buffer. +pub fn render( + level: Level, + tiles: &Tiles, + strip_buf: &mut Vec, + alpha_buf: &mut Vec, + fill_rule: Fill, + aliasing_threshold: Option, + lines: &[Line], +) { + dispatch!(level, simd => render_impl(simd, + tiles, + strip_buf, + alpha_buf, + fill_rule, + aliasing_threshold, + lines)); +} + +#[inline(always)] +fn render_impl( + s: S, + tiles: &Tiles, + strip_buf: &mut Vec, + alpha_buf: &mut Vec, + fill_rule: Fill, + aliasing_threshold: Option, + lines: &[Line], +) { + let row_windings = &tiles.windings.coarse; + let has_culled_tiles = tiles.has_culled_tiles(); + + // If no tiles were culled and the tile buffer is empty, we can simply exit. If tiles were + // culled, the tile buffer may be empty but there may be winding produced by culled geometry + // left of the viewport that must be checked for filling. + if !has_culled_tiles && tiles.is_empty() { + return; + } + + let should_fill = |winding: i32| match fill_rule { + Fill::NonZero => winding != 0, + Fill::EvenOdd => winding % 2 != 0, + }; + + // Helper to handle "captive strips". When a row has tiles, but the first tile + // is not at the left edge of the viewport (x != 0), we must emit a solid strip + // from x=0 to that tile if the coarse winding dictates a fill. + let emit_captive_strip = + |y: u16, is_left_viewport: bool, strips: &mut Vec, alphas: &mut Vec| { + let coarse_wd = tiles.windings.coarse[y as usize] as i32; + + if should_fill(coarse_wd) && !is_left_viewport { + strips.push(Strip::new(0, y * Tile::HEIGHT, alphas.len() as u32, false)); + alphas.extend([255_u8; Tile::HEIGHT as usize * Tile::WIDTH as usize]); + } + + let mut acc = f32x4::splat(s, coarse_wd as f32); + if is_left_viewport { + let fine_winding: f32x4<_> = tiles.windings.partial[y as usize].simd_into(s); + acc += fine_winding; + } + + (coarse_wd, acc) + }; + + // The accumulated tile winding delta. A line that crosses the top edge of a tile + // increments the delta if the line is directed upwards, and decrements it if goes + // downwards. Horizontal lines leave it unchanged. + let mut winding_delta: i32 = 0; + + // The previous tile visited. + let mut prev_tile = if has_culled_tiles && tiles.is_empty() { + Tile::SENTINEL + } else { + *tiles.get(0) + }; + + // The accumulated (fractional) winding of the tile-sized location we're currently at. + // Note multiple tiles can be at the same location. + // Note that we are also implicitly assuming here that the tile height exactly fits into a + // SIMD vector (i.e. 128 bits). + let mut location_winding = [f32x4::splat(s, 0.0); Tile::WIDTH as usize]; + // The accumulated (fractional) windings at this location's right edge. When we move to the + // next location, this is splatted to that location's starting winding. + let mut accumulated_winding = f32x4::splat(s, 0.0); + + let left_viewport = prev_tile.x == 0; + if has_culled_tiles { + let row_max = prev_tile.y.min(row_windings.len() as u16); + Strip::emit_culled_background( + 0, + row_max, + strip_buf, + alpha_buf, + &tiles.windings, + should_fill, + ); + if tiles.is_empty() { + return; + } + let (wd, acc) = emit_captive_strip(prev_tile.y, left_viewport, strip_buf, alpha_buf); + winding_delta = wd; + accumulated_winding = acc; + location_winding = [accumulated_winding; Tile::WIDTH as usize]; + } + + // The strip we're building. + let mut strip = Strip::new( + prev_tile.x * Tile::WIDTH, + prev_tile.y * Tile::HEIGHT, + alpha_buf.len() as u32, + should_fill(winding_delta) && !left_viewport, + ); + + for (tile_idx, tile) in tiles.iter().copied().chain([Tile::SENTINEL]).enumerate() { + let line = lines[tile.line_idx() as usize]; + let tile_left_x = f32::from(tile.x) * f32::from(Tile::WIDTH); + let tile_top_y = f32::from(tile.y) * f32::from(Tile::HEIGHT); + let p0_x = line.p0.x - tile_left_x; + let p0_y = line.p0.y - tile_top_y; + let p1_x = line.p1.x - tile_left_x; + let p1_y = line.p1.y - tile_top_y; + + // Push out the winding as an alpha mask when we move to the next location (i.e., a tile + // without the same location). + if !prev_tile.same_loc(&tile) { + match fill_rule { + Fill::NonZero => { + let p1 = f32x4::splat(s, 0.5); + let p2 = f32x4::splat(s, 255.0); + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + let area = location_winding[x]; + let coverage = area.abs(); + let mulled = coverage.mul_add(p2, p1); + // Note that we are not storing the location winding here but the actual + // alpha value as f32, so we reuse the variable as a temporary storage. + // Also note that we need the `min` here because the winding can be > 1 + // and thus the calculated alpha value need to be clamped to 255. + location_winding[x] = mulled.min(p2); + } + } + Fill::EvenOdd => { + let p1 = f32x4::splat(s, 0.5); + let p2 = f32x4::splat(s, -2.0); + let p3 = f32x4::splat(s, 255.0); + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + let area = location_winding[x]; + let im1 = area.mul_add(p1, p1).floor(); + let coverage = p2.mul_add(im1, area).abs(); + let mulled = p3.mul_add(coverage, p1); + // TODO: It is possible that, unlike for `NonZero`, we don't need the `min` + // here. + location_winding[x] = mulled.min(p3); + } + } + }; + + let p1 = s.combine_f32x4(location_winding[0], location_winding[1]); + let p2 = s.combine_f32x4(location_winding[2], location_winding[3]); + + let mut u8_vals = f32_to_u8(s.combine_f32x8(p1, p2)); + + if let Some(aliasing_threshold) = aliasing_threshold { + u8_vals = s.select_u8x16( + u8_vals.simd_ge(u8x16::splat(s, aliasing_threshold)), + u8x16::splat(s, 255), + u8x16::splat(s, 0), + ); + } + + alpha_buf.extend_from_slice(u8_vals.as_slice()); + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + location_winding[x] = accumulated_winding; + } + } + + // Push out the strip if we're moving to a next strip. + if !prev_tile.same_loc(&tile) && !prev_tile.prev_loc(&tile) { + debug_assert_eq!( + (prev_tile.x as u32 + 1) * Tile::WIDTH as u32 - strip.x as u32, + ((alpha_buf.len() - strip.alpha_idx() as usize) / usize::from(Tile::HEIGHT)) as u32, + "The number of columns written to the alpha buffer should equal the number of columns spanned by this strip." + ); + strip_buf.push(strip); + + let is_sentinel = tile_idx == tiles.len() as usize; + let left_viewport = tile.x == 0; + if !prev_tile.same_row(&tile) { + // Emit a final strip in the row if there is non-zero winding for the sparse fill, + // or unconditionally if we've reached the sentinel tile to end the path (the + // `alpha_idx` field is used for width calculations). + if winding_delta != 0 || is_sentinel { + strip_buf.push(Strip::new( + u16::MAX, + prev_tile.y * Tile::HEIGHT, + alpha_buf.len() as u32, + should_fill(winding_delta), + )); + } + + // Logic identical to the start (see above): fill any vertical gaps (empty rows) + // between the previous and current tile using the row windings. + if has_culled_tiles && !is_sentinel { + Strip::emit_culled_background( + prev_tile.y + 1, + tile.y, + strip_buf, + alpha_buf, + &tiles.windings, + should_fill, + ); + + let (wd, acc) = emit_captive_strip(tile.y, left_viewport, strip_buf, alpha_buf); + winding_delta = wd; + accumulated_winding = acc; + } else { + winding_delta = 0; + accumulated_winding = f32x4::splat(s, 0.0); + }; + + #[expect(clippy::needless_range_loop, reason = "dimension clarity")] + for x in 0..Tile::WIDTH as usize { + location_winding[x] = accumulated_winding; + } + } else { + // Note: this fill is mathematically not necessary. It provides a way to reduce + // accumulation of float rounding errors. + accumulated_winding = f32x4::splat(s, winding_delta as f32); + } + + if is_sentinel { + break; + } + + strip = Strip::new( + tile.x * Tile::WIDTH, + tile.y * Tile::HEIGHT, + alpha_buf.len() as u32, + should_fill(winding_delta) && !left_viewport, + ); + } + prev_tile = tile; + + // TODO: horizontal geometry has no impact on winding. This branch will be removed when + // horizontal geometry is culled at the tile-generation stage. + if p0_y == p1_y { + continue; + } + + // Lines moving upwards (in a y-down coordinate system) add to winding; lines moving + // downwards subtract from winding. + let sign = (p0_y - p1_y).signum(); + + // Calculate winding / pixel area coverage. + // + // Conceptually, horizontal rays are shot from left to right. Every time the ray crosses a + // line that is directed upwards (decreasing `y`), the winding is incremented. Every time + // the ray crosses a line moving downwards (increasing `y`), the winding is decremented. + // The fractional area coverage of a pixel is the integral of the winding within it. + // + // Practically, to calculate this, each pixel is considered individually, and we determine + // whether the line moves through this pixel. The line's y-delta within this pixel is + // accumulated and added to the area coverage of pixels to the right. Within the pixel + // itself, the area to the right of the line segment forms a trapezoid (or a triangle in + // the degenerate case). The area of this trapezoid is added to the pixel's area coverage. + // + // For example, consider the following pixel square, with a line indicated by asterisks + // starting inside the pixel and crossing its bottom edge. The area covered is the + // trapezoid on the bottom-right enclosed by the line and the pixel square. The area is + // positive if the line moves down, and negative otherwise. + // + // __________________ + // | | + // | *------| + // | * | + // | * | + // | * | + // | * | + // | * | + // |___*____________| + // * + // * + + let (line_top_y, line_top_x, line_bottom_y, line_bottom_x) = if p0_y < p1_y { + (p0_y, p0_x, p1_y, p1_x) + } else { + (p1_y, p1_x, p0_y, p0_x) + }; + + let y_slope = (line_bottom_y - line_top_y) / (line_bottom_x - line_top_x); + let x_slope = 1. / y_slope; + + winding_delta += sign as i32 * i32::from(tile.winding()); + + let line_top_y = f32x4::splat(s, line_top_y); + let line_bottom_y = f32x4::splat(s, line_bottom_y); + + // See the explanation of this term on the `line_px_left_yx` and `line_px_right_yx` + // variables below. + let line_px_base_yx = line_top_y.mul_add(-x_slope, line_top_x); + + let px_top_y = f32x4::simd_from(s, [0., 1., 2., 3.]); + let px_bottom_y = 1. + px_top_y; + + let ymin = line_top_y.max(px_top_y); + let ymax = line_bottom_y.min(px_bottom_y); + + let mut acc = f32x4::splat(s, 0.0); + + for x_idx in 0..Tile::WIDTH { + let x_idx_s = f32x4::splat(s, x_idx as f32); + let px_left_x = x_idx_s; + let px_right_x = 1.0 + x_idx_s; + + // The y-coordinate of the intersections between the line and the pixel's left and + // right edges respectively. + // + // There is some subtlety going on here: `y_slope` will usually be finite, but will + // be `inf` for purely vertical lines (`p0_x == p1_x`). + // + // In the case of `inf`, the resulting slope calculation will be `-inf` or `inf` + // depending on whether the pixel edge is left or right of the line, respectively + // (from the viewport's coordinate system perspective). The `min` and `max` + // y-clamping logic generalizes nicely, as a pixel edge to the left of the line is + // clamped to `ymin`, and a pixel edge to the right is clamped to `ymax`. + // + // In the special case where a vertical line and pixel edge are at the exact same + // x-position (collinear), the line belongs to the pixel on whose _left_ edge it is + // situated. The resulting slope calculation for the edge the line is situated on + // will be NaN, as `0 * inf` results in NaN. This is true for both the left and + // right edge. + // + // We know `ymin` and `ymax` are finite. We require the `max` operation to pick `ymin` + // if its first operand is NaN. On x86, that maps to the semantics of `_mm_max_ps`, + // which `f32x4::max` emits: that instruction takes element-wise + // `if first > second { first } else { second }`. For AArch64, we do require the + // `f32x4::max_precise` semantics (as `vmax_f32` returns NaN if either operand is NaN); + // however, for AArch64 the precise version should be comparatively less expensive than + // on x86. For `min`, we then know both operands are finite, so we can unambiguously + // use the relaxed version. If this ever breaks, tests should fail loudly, because NaNs + // happen a lot here! + trait F32x4MaxExt { + fn max_if_first_nan_take_second(self, rhs: Self) -> Self; + } + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + impl F32x4MaxExt for f32x4 { + #[inline(always)] + fn max_if_first_nan_take_second(self, rhs: Self) -> Self { + self.max(rhs) + } + } + #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] + impl F32x4MaxExt for f32x4 { + #[inline(always)] + fn max_if_first_nan_take_second(self, rhs: Self) -> Self { + self.max_precise(rhs) + } + } + let line_px_left_y = (px_left_x - line_top_x) + .mul_add(y_slope, line_top_y) + .max_if_first_nan_take_second(ymin) + .min(ymax); + let line_px_right_y = (px_right_x - line_top_x) + .mul_add(y_slope, line_top_y) + .max_if_first_nan_take_second(ymin) + .min(ymax); + + // For each pixel we calculate the x-coordinates of the left- and rightmost points on + // the line segment within that pixel. We do this based on the y-offsets of those two + // points from the top of the line. This can be calculated as, e.g., + // `(line_px_left_y - line_top_y) * x_slope + line_top_x`. + // + // Rather than calculating that y-offset twice for each pixel within the loop through + // subtracting from the points' y-coordinates, we get rid of that subtraction by baking + // it into the algebraic "base" x-coordinate `line_px_base_yx` calculated above the + // loop. When adding that term to `y * x_slope` it gives the x-coordinate of the point + // along the line. + // + // Note `x_slope` is always finite, as horizontal geometry is elided. + let line_px_left_yx = line_px_left_y.mul_add(x_slope, line_px_base_yx); + let line_px_right_yx = line_px_right_y.mul_add(x_slope, line_px_base_yx); + let h = (line_px_right_y - line_px_left_y).abs(); + + // The trapezoidal area enclosed between the line and the right edge of the pixel + // square. More straightforwardly written as follows, but the `madd` is faster. + // 0.5 * h * (2. * px_right_x - line_px_right_yx - line_px_left_yx). + let area = h * (line_px_right_yx + line_px_left_yx).mul_add(-0.5, px_right_x); + location_winding[x_idx as usize] += area.mul_add(sign, acc); + acc = h.mul_add(sign, acc); + } + + accumulated_winding += acc; + } + + if has_culled_tiles { + Strip::emit_culled_background( + (prev_tile.y + 1).min(row_windings.len() as u16), + row_windings.len() as u16, + strip_buf, + alpha_buf, + &tiles.windings, + should_fill, + ); + } +} diff --git a/vendor/vello_common/src/strip_generator.rs b/vendor/vello_common/src/strip_generator.rs new file mode 100644 index 0000000000..9a8bfc237b --- /dev/null +++ b/vendor/vello_common/src/strip_generator.rs @@ -0,0 +1,440 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Abstraction for generating strips from paths. + +use crate::clip::{PathDataRef, intersect}; +use crate::fearless_simd::Level; +use crate::flatten::{FlattenCtx, Line}; +use crate::geometry::RectU16; +use crate::kurbo::{Affine, PathEl, Rect, Stroke}; +use crate::peniko::Fill; +use crate::strip::Strip; +use crate::tile::Tiles; +use crate::{flatten, rect, strip}; +use alloc::vec::Vec; +use peniko::kurbo::StrokeCtx; + +/// A storage for storing strip-related data. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct StripStorage { + /// The strips in the storage. + pub strips: Vec, + /// The alphas in the storage. + pub alphas: Vec, + generation_mode: GenerationMode, +} + +/// The generation mode of the strip storage. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)] +pub enum GenerationMode { + #[default] + /// Clear strips before generating the new ones. + Replace, + /// Don't clear strips, append to the existing buffer. + Append, + /// Truncate strips to the given index before generating new ones, + /// preserving strips in `[0..n]`. + ReplaceAfter(usize), +} + +impl StripStorage { + /// Create a new strip storage with the given generation mode. + pub fn new(generation_mode: GenerationMode) -> Self { + Self { + strips: Vec::new(), + alphas: Vec::new(), + generation_mode, + } + } + + /// Reset the storage. + pub fn clear(&mut self) { + self.strips.clear(); + self.alphas.clear(); + } + + /// Get the current generation mode. + pub fn generation_mode(&self) -> GenerationMode { + self.generation_mode + } + + /// Set the generation mode of the storage. + pub fn set_generation_mode(&mut self, mode: GenerationMode) { + self.generation_mode = mode; + } + + /// Whether the strip storage is empty. + pub fn is_empty(&self) -> bool { + self.strips.is_empty() && self.alphas.is_empty() + } + + /// Extend the current strip storage with the data from another storage. + pub fn extend(&mut self, other: &Self) { + self.strips.extend(&other.strips); + self.alphas.extend(&other.alphas); + } +} + +/// An object for easily generating strips for a filled/stroked path. +#[derive(Debug)] +pub struct StripGenerator { + pub(crate) level: Level, + line_buf: Vec, + flatten_ctx: FlattenCtx, + stroke_ctx: StrokeCtx, + temp_storage: StripStorage, + tiles: Tiles, + width: u16, + height: u16, +} + +impl StripGenerator { + /// Create a new strip generator. + pub fn new(width: u16, height: u16, level: Level) -> Self { + Self { + level, + line_buf: Vec::new(), + tiles: Tiles::new(level, height), + flatten_ctx: FlattenCtx::default(), + stroke_ctx: StrokeCtx::default(), + temp_storage: StripStorage::default(), + width, + height, + } + } + + /// Get this strip generator's viewport width. + #[inline(always)] + pub fn width(&self) -> u16 { + self.width + } + + /// Get this strip generator's viewport height. + #[inline(always)] + pub fn height(&self) -> u16 { + self.height + } + + /// Generate the strips for a filled path. + pub fn generate_filled_path( + &mut self, + path: impl IntoIterator, + fill_rule: Fill, + transform: Affine, + aliasing_threshold: Option, + strip_storage: &mut StripStorage, + clip_path: Option>, + ) { + let cull_bbox = clip_path + .map(|clip_path| clip_path.bbox) + .unwrap_or(RectU16::new(0, 0, self.width, self.height)); + flatten::fill( + self.level, + path, + transform, + &mut self.line_buf, + &mut self.flatten_ctx, + cull_bbox, + ); + + self.generate_with_clip(aliasing_threshold, strip_storage, fill_rule, clip_path); + } + + /// Generate the strips for a stroked path. + pub fn generate_stroked_path( + &mut self, + path: impl IntoIterator, + stroke: &Stroke, + transform: Affine, + aliasing_threshold: Option, + strip_storage: &mut StripStorage, + clip_path: Option>, + ) { + let cull_bbox = clip_path + .map(|clip_path| clip_path.bbox) + .unwrap_or(RectU16::new(0, 0, self.width, self.height)); + flatten::stroke( + self.level, + path, + stroke, + transform, + &mut self.line_buf, + &mut self.flatten_ctx, + &mut self.stroke_ctx, + cull_bbox, + ); + self.generate_with_clip(aliasing_threshold, strip_storage, Fill::NonZero, clip_path); + } + + fn generate_with_clip( + &mut self, + aliasing_threshold: Option, + strip_storage: &mut StripStorage, + fill_rule: Fill, + clip_path: Option>, + ) { + self.tiles + .make_tiles_analytic_aa(self.level, &self.line_buf, self.width, self.height); + + self.tiles.sort_tiles(); + + let level = self.level; + let tiles = &self.tiles; + let line_buf = &self.line_buf; + render_with_clip( + level, + &mut self.temp_storage, + strip_storage, + clip_path, + |strips, alphas| { + strip::render( + level, + tiles, + strips, + alphas, + fill_rule, + aliasing_threshold, + line_buf, + ); + }, + ); + } + + /// Generate strips directly for a pixel-aligned rectangle. + /// + /// This bypasses the full path processing pipeline (flatten -> tiles -> strips) + /// by directly creating strip coverage data for the rectangle. + pub fn generate_filled_rect_fast( + &mut self, + rect: &Rect, + strip_storage: &mut StripStorage, + clip_path: Option>, + ) { + let viewport = Rect::new(0.0, 0.0, self.width as f64, self.height as f64); + let clip_bbox = clip_path + .map(|clip| { + // Clip bbox is always guaranteed to be within viewport bounds, so no need to + // intersect again. + Rect::new( + f64::from(clip.bbox.x0), + f64::from(clip.bbox.y0), + f64::from(clip.bbox.x1), + f64::from(clip.bbox.y1), + ) + }) + .unwrap_or(viewport); + let clamped = rect.abs().intersect(clip_bbox); + + let level = self.level; + render_with_clip( + level, + &mut self.temp_storage, + strip_storage, + clip_path, + |strips, alphas| { + rect::render(level, clamped, strips, alphas); + }, + ); + } + + /// Reset the strip generator. + pub fn reset(&mut self) { + self.line_buf.clear(); + self.tiles.reset(); + self.temp_storage.clear(); + } +} + +/// Render strips via `render_fn` with optional clip intersection. +/// +/// When `clip_path` is `Some`, strips are rendered into `temp_storage` first, then +/// intersected with the clip mask into `strip_storage`. Otherwise strips are rendered +/// directly into `strip_storage`. +fn render_with_clip( + level: Level, + temp_storage: &mut StripStorage, + strip_storage: &mut StripStorage, + clip_path: Option>, + render_fn: impl FnOnce(&mut Vec, &mut Vec), +) { + match strip_storage.generation_mode { + GenerationMode::Replace => strip_storage.strips.clear(), + GenerationMode::Append => {} + GenerationMode::ReplaceAfter(n) => strip_storage.strips.truncate(n), + } + + if let Some(clip_path) = clip_path { + temp_storage.clear(); + + render_fn(&mut temp_storage.strips, &mut temp_storage.alphas); + + let path_data = PathDataRef { + strips: &temp_storage.strips, + alphas: &temp_storage.alphas, + bbox: RectU16::new(0, 0, u16::MAX, u16::MAX), + }; + intersect(level, clip_path, path_data, strip_storage); + } else { + render_fn(&mut strip_storage.strips, &mut strip_storage.alphas); + } +} + +#[cfg(test)] +mod tests { + use alloc::format; + + use crate::fearless_simd::Level; + use crate::kurbo::{Affine, Rect, Shape}; + use crate::peniko::Fill; + use crate::strip_generator::{StripGenerator, StripStorage}; + + #[test] + fn reset() { + let mut generator = StripGenerator::new(100, 100, Level::baseline()); + let mut storage = StripStorage::default(); + let rect = Rect::new(0.0, 0.0, 100.0, 100.0); + + generator.generate_filled_path( + rect.to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + None, + &mut storage, + None, + ); + + assert!(!generator.line_buf.is_empty()); + assert!(!storage.is_empty()); + + generator.reset(); + storage.clear(); + + assert!(generator.line_buf.is_empty()); + assert!(storage.is_empty()); + } + + /// Assert that `generate_filled_rect_fast` produces the same strips as the + /// path-based pipeline for the given rectangle. + fn assert_rect_fast_eq_path(rect: Rect, test_name: &str) { + let mut generator = StripGenerator::new(100, 100, Level::baseline()); + let mut storage_path = StripStorage::default(); + let mut storage_rect = StripStorage::default(); + + generator.generate_filled_path( + rect.to_path(0.1), + Fill::NonZero, + Affine::IDENTITY, + None, + &mut storage_path, + None, + ); + generator.reset(); + + generator.generate_filled_rect_fast(&rect, &mut storage_rect, None); + + assert_eq!( + storage_path.strips, storage_rect.strips, + "{test_name}: strips mismatch", + ); + assert_eq!( + storage_path.alphas, storage_rect.alphas, + "{test_name}: alphas mismatch", + ); + } + + #[test] + fn rect_small_single_tile() { + assert_rect_fast_eq_path(Rect::new(1.0, 1.0, 3.0, 3.0), "small_single_tile"); + } + + #[test] + fn rect_spanning_multiple_tiles_horizontally() { + assert_rect_fast_eq_path(Rect::new(2.0, 1.0, 14.0, 3.0), "spanning_horizontal"); + } + + #[test] + fn rect_spanning_multiple_tiles_vertically() { + assert_rect_fast_eq_path(Rect::new(1.0, 2.0, 3.0, 14.0), "spanning_vertical"); + } + + #[test] + fn rect_spanning_multiple_tiles_both_directions() { + assert_rect_fast_eq_path(Rect::new(2.0, 2.0, 18.0, 18.0), "spanning_both"); + } + + #[test] + fn rect_tile_aligned() { + assert_rect_fast_eq_path(Rect::new(0.0, 0.0, 8.0, 8.0), "tile_aligned"); + } + + #[test] + fn rect_one_pixel_wide() { + assert_rect_fast_eq_path(Rect::new(5.0, 2.0, 6.0, 12.0), "one_pixel_wide"); + } + + #[test] + fn rect_one_pixel_tall() { + assert_rect_fast_eq_path(Rect::new(2.0, 5.0, 12.0, 6.0), "one_pixel_tall"); + } + + #[test] + fn rect_fractional_within_single_tile() { + let cases: &[(f64, f64, f64, f64)] = &[ + (0.25, 0.75, 2.5, 3.5), + (1.2, 1.3, 1.8, 1.7), + (0.1, 0.1, 3.9, 3.9), + (2.5, 2.5, 2.6, 2.6), + (0.01, 0.99, 3.99, 3.01), + ]; + for (i, &(x0, y0, x1, y1)) in cases.iter().enumerate() { + assert_rect_fast_eq_path(Rect::new(x0, y0, x1, y1), &format!("single_tile_{i}")); + } + } + + #[test] + fn rect_fractional_multi_tile() { + let cases: &[(f64, f64, f64, f64)] = &[ + (1.5, 2.3, 10.7, 8.9), + (0.5, 0.5, 8.5, 8.5), + (2.3, 5.1, 15.7, 5.9), + (5.1, 2.3, 5.9, 15.7), + (0.25, 0.25, 12.75, 12.75), + (1.0 / 3.0, 2.0 / 3.0, 10.33, 8.67), + (1.99, 2.01, 9.01, 7.99), + (3.9, 3.9, 8.1, 8.1), + (3.2, 6.3, 14.8, 6.7), + (6.3, 3.2, 6.7, 14.8), + (0.1, 0.9, 49.9, 49.1), + (4.0, 2.7, 12.0, 9.3), + (2.7, 4.0, 9.3, 12.0), + (1.5, 1.2, 10.5, 2.8), + (1.5, 2.5, 14.5, 18.5), + (0.7, 0.3, 30.2, 25.8), + (7.9, 7.9, 8.1, 8.1), + (3.5, 0.5, 4.5, 0.9), + (0.01, 0.01, 99.99, 99.99), + (10.0, 10.0, 10.1, 10.1), + ]; + for (i, &(x0, y0, x1, y1)) in cases.iter().enumerate() { + assert_rect_fast_eq_path(Rect::new(x0, y0, x1, y1), &format!("multi_tile_{i}")); + } + } + + #[test] + fn rect_fractional_exhaustive() { + for xi in 0..100_u32 { + for yi in 0..100_u32 { + let dx = xi as f64 * 0.01; + let dy = yi as f64 * 0.01; + let rect = Rect::new(dx, dy, 50.0 + dx, 50.0 + dy); + assert_rect_fast_eq_path(rect, &format!("exhaustive_{dx}_{dy}")); + } + } + } + + #[test] + fn rect_inverted_both_axes() { + assert_rect_fast_eq_path(Rect::new(18.0, 18.0, 2.0, 2.0), "inverted_both_axes"); + } +} diff --git a/vendor/vello_common/src/tile.rs b/vendor/vello_common/src/tile.rs new file mode 100644 index 0000000000..92e3da4184 --- /dev/null +++ b/vendor/vello_common/src/tile.rs @@ -0,0 +1,2426 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Primitives for creating tiles. + +use crate::flatten::Line; +use alloc::vec; +use alloc::vec::Vec; +use fearless_simd::*; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +/// T-op bit +const T: u32 = 0b00001; +/// B-ottom bit +const B: u32 = 0b00010; +/// L-eft bit +const L: u32 = 0b00100; +/// R-ight bit +const R: u32 = 0b01000; +/// W-inding bit +const W: u32 = 0b10000; + +/// Shift amount corresponding to the bottom bit. +const BOT_SHIFT: u32 = B.trailing_zeros(); +/// Shift amount corresponding to the left bit. +const LEFT_SHIFT: u32 = L.trailing_zeros(); +/// Shift amount corresponding to the right bit. +const RIGHT_SHIFT: u32 = R.trailing_zeros(); +/// Shift amount corresponding to the winding bit. +const WINDING_SHIFT: u32 = W.trailing_zeros(); + +/// Mask for all intersection and winding bits (Bits 0-4). +const INTERSECTION_MASK: u32 = W | R | L | B | T; +/// Shift amount corresponding to the intersection bits. +const INT_MASK_SHIFT: u32 = INTERSECTION_MASK.count_ones(); + +/// The max number of lines per path. +/// +/// Trying to render a path with more lines than this may result in visual artifacts. +pub const MAX_LINES_PER_PATH: u32 = 1 << (32 - INT_MASK_SHIFT); + +/// A logical grouping of arrays used for culled tile processing, +#[derive(Debug, Clone, Default)] +pub struct CulledWindings { + /// Fractional winding coverage for each individual scanline in a row. + pub partial: Vec<[f32; Tile::HEIGHT as usize]>, + // Note that this will cause issues if we have windings greater/less than i8, + // but this should only occur in pathological cases. + /// Accumulated integer winding deltas for each tile row. + pub coarse: Vec, + /// Bitmask tracking which rows contain active geometry or winding data. + pub active: Vec, + /// Flag indicating if any geometry was early-culled outside the viewport. + pub culled: bool, +} + +impl CulledWindings { + /// Number of bits in a single active mask word. + const WORD_BITS: usize = 32; + /// Bit shift equivalent to dividing by `WORD_BITS` (2^5 = 32). + const WORD_SHIFT: usize = 5; + /// Bitmask equivalent to modulo `WORD_BITS` (32 - 1 = 31). + const WORD_MASK: usize = 31; + + /// Constructor chained to `Tiles`' constructor and matches its lifetime. Since `Tiles` itself + /// matches the lifetime of `StripGenerator`, we know that the viewport dimensions will never + /// change, and thus the backing vecs never need to be resized. (For now). + pub fn new(height: u16) -> Self { + let height_usize = height as usize; + let tile_height = Tile::HEIGHT as usize; + let num_rows = height_usize.div_ceil(tile_height); + let num_bits = num_rows.div_ceil(Self::WORD_BITS); + + Self { + partial: vec![[0.0; Tile::HEIGHT as usize]; num_rows], + coarse: vec![0; num_rows], + active: vec![0; num_bits], + culled: false, + } + } + + /// Clears but does not resize + pub fn reset(&mut self) { + // TODO: Maybe consider tracking touched regions and only resetting those + // instead of always the full array? + if self.culled { + self.partial.fill([0.0; Tile::HEIGHT as usize]); + self.coarse.fill(0); + self.active.fill(0); + self.culled = false; + } + } + + /// Marks if a row was culled early for faster traversal in strip generation. + #[inline(always)] + pub fn mark_row_active(&mut self, row_idx: usize) { + self.active[row_idx >> Self::WORD_SHIFT] |= 1 << (row_idx & Self::WORD_MASK); + } + + /// Bulk marks a range of rows as active [`start_row`, `end_row`). + #[inline(always)] + pub fn mark_row_range_active(&mut self, start_row: usize, end_row: usize) { + if start_row >= end_row { + return; + } + + let start_word = start_row >> Self::WORD_SHIFT; + let end_word = (end_row - 1) >> Self::WORD_SHIFT; + + if start_word == end_word { + // All bits fall within the same u32 word + let shift = start_row & Self::WORD_MASK; + let count = end_row - start_row; + let mask = if count == Self::WORD_BITS { + u32::MAX + } else { + ((1_u32 << count) - 1) << shift + }; + self.active[start_word] |= mask; + } else { + // Bits span multiple words: handle start, full middle words, and end + self.active[start_word] |= u32::MAX << (start_row & Self::WORD_MASK); + + self.active[(start_word + 1)..end_word].fill(u32::MAX); + + let end_shift = ((end_row - 1) & Self::WORD_MASK) + 1; + let mask = if end_shift == Self::WORD_BITS { + u32::MAX + } else { + (1_u32 << end_shift) - 1 + }; + self.active[end_word] |= mask; + } + } + + /// Calls `f` on active rows in the range [`start`, `end`). + #[inline(always)] + pub fn for_active_rows_in_range(&self, start: usize, end: usize, mut f: F) + where + F: FnMut(usize), + { + if start >= end { + return; + } + + let start_word = start >> Self::WORD_SHIFT; + let end_word = (end - 1) >> Self::WORD_SHIFT; + + let mut process_word = |mut word: u32, word_idx: usize| { + while word != 0 { + let bit = word.trailing_zeros(); + word &= !(1_u32 << bit); + f((word_idx << Self::WORD_SHIFT) + bit as usize); + } + }; + + let end_limit = ((end - 1) & Self::WORD_MASK) + 1; + let end_mask = if end_limit == Self::WORD_BITS { + u32::MAX + } else { + (1_u32 << end_limit) - 1 + }; + + // Start Word + { + let mut word = self.active[start_word]; + let start_bit = start & Self::WORD_MASK; + word &= !((1_u32 << start_bit) - 1); + + if start_word == end_word { + word &= end_mask; + } + + process_word(word, start_word); + } + + // Middle Words & End Word + if start_word < end_word { + for (word_idx, &word_val) in self + .active + .iter() + .enumerate() + .take(end_word) + .skip(start_word + 1) + { + process_word(word_val, word_idx); + } + + // End Word + process_word(self.active[end_word] & end_mask, end_word); + } + } +} + +/// A tile represents an aligned area on the pixmap, used to subdivide the viewport into sub-areas +/// (currently 4x4) and analyze line intersections inside each such area. +/// +/// Keep in mind that it is possible to have multiple tiles with the same index, +/// namely if we have multiple lines crossing the same 4x4 area! +/// +/// # Note +/// +/// This struct is `#[repr(C)]`, but the byte order of its fields is dependent on the endianness of +/// the compilation target. +#[derive(Debug, Clone, Copy)] +#[repr(C)] +pub struct Tile { + // The field ordering is important. + // + // The given ordering (variant over little and big endian compilation targets), ensures that + // `Tile::to_bits` doesn't do any actual work, as the ordering of the fields is such that the + // numeric value of a `Tile` in memory is identical as returned by that method. This allows + // for, e.g., comparison and sorting. + #[cfg(target_endian = "big")] + /// The index of the tile in the y direction. + pub y: u16, + + #[cfg(target_endian = "big")] + /// The index of the tile in the x direction. + pub x: u16, + + /// The index of the line this tile belongs to into the line buffer, intersection data, + /// and winding data packed together. + /// + /// The layout is: + /// - **Bits 0-4 (5 bits):** Intersection and Winding Mask (`W | R | L | B | T`). + /// - Bit 0 (mask `0b00001`): Intersects top edge (T) + /// - Bit 1 (mask `0b00010`): Intersects bottom edge (B) + /// - Bit 2 (mask `0b00100`): Intersects left edge (L) + /// - Bit 3 (mask `0b01000`): Intersects right edge (R) + /// - Bit 4 (mask `0b10000`): Winding (W) - 1 if crosses top edge. + /// - **Bits 5-31 (27 bits):** The line index (`line_idx`). + /// + /// **Sorting Note:** The `line_idx` occupies the higher bits to ensure that when sorting + /// tiles with the same (x, y) coordinates, they are sorted by their line index first, + /// and then by their intersection mask. + pub packed_winding_line_idx: u32, + + #[cfg(target_endian = "little")] + /// The index of the tile in the x direction. + pub x: u16, + + #[cfg(target_endian = "little")] + /// The index of the tile in the y direction. + pub y: u16, +} + +impl Tile { + /// The width of a tile in pixels. + pub const WIDTH: u16 = 4; + + /// The height of a tile in pixels. + pub const HEIGHT: u16 = 4; + + /// A special tile used to signal the end of a tile stream during rendering. + pub const SENTINEL: Self = Self::new(u16::MAX, u16::MAX, 0, 0); + + /// Create a new tile. + /// `x` and `y` will be clamped to the largest possible coordinate if they are too large. + /// + /// `line_idx` must be smaller than [`MAX_LINES_PER_PATH`]. + #[inline] + pub fn new_clamped(x: u16, y: u16, line_idx: u32, intersection_mask: u32) -> Self { + Self::new( + // Make sure that x and y stay in range when multiplying + // with the tile width and height during strips generation. + x.min(u16::MAX / Self::WIDTH), + y.min(u16::MAX / Self::HEIGHT), + line_idx, + intersection_mask, + ) + } + + /// The base tile constructor + /// + /// Unlike [`Self::new_clamped`], this constructor stores `x` and `y` exactly as provided. + /// Callers must ensure these coordinates do not exceed the limits required by downstream + /// processing (typically `u16::MAX / WIDTH` and `u16::MAX / HEIGHT`). + #[inline] + pub const fn new(x: u16, y: u16, line_idx: u32, intersection_mask: u32) -> Self { + #[cfg(debug_assertions)] + if line_idx >= MAX_LINES_PER_PATH { + panic!("Max. number of lines per path exceeded."); + } + // The intersection_mask is expected to contain bits 0-4 (T, B, L, R, W). + // We pack line_idx into the high bits (5-31) and intersection_mask into low bits (0-4). + Self { + x, + y, + packed_winding_line_idx: (line_idx << INT_MASK_SHIFT) | intersection_mask, + } + } + + /// Check whether two tiles are at the same location. + #[inline] + pub const fn same_loc(&self, other: &Self) -> bool { + self.same_row(other) && self.x == other.x + } + + /// Check whether `self` is adjacent to the left of `other`. + #[inline] + pub const fn prev_loc(&self, other: &Self) -> bool { + self.same_row(other) && self.x + 1 == other.x + } + + /// Check whether two tiles are on the same row. + #[inline] + pub const fn same_row(&self, other: &Self) -> bool { + self.y == other.y + } + + /// The index of the line this tile belongs to into the line buffer. + /// + /// Returns the high 27 bits. + #[inline] + pub const fn line_idx(&self) -> u32 { + self.packed_winding_line_idx >> INT_MASK_SHIFT + } + + /// Whether the line crosses the top edge of the tile. + /// + /// Lines making this crossing increment or decrement the coarse tile winding, depending on the + /// line direction. + /// + /// Checks Bit 4 (Winding). + #[inline] + pub const fn winding(&self) -> bool { + (self.packed_winding_line_idx & W) != 0 + } + + /// The 5 bits of intersection and winding data. + #[inline] + pub const fn intersection_mask(&self) -> u32 { + self.packed_winding_line_idx & INTERSECTION_MASK + } + + /// Whether the line intersects the top edge of the tile. + #[inline] + pub const fn intersects_top(&self) -> bool { + (self.intersection_mask() & T) != 0 + } + + /// Whether the line intersects the bottom edge of the tile. + #[inline] + pub const fn intersects_bottom(&self) -> bool { + (self.intersection_mask() & B) != 0 + } + + /// Whether the line intersects the left edge of the tile. + #[inline] + pub const fn intersects_left(&self) -> bool { + (self.intersection_mask() & L) != 0 + } + + /// Whether the line intersects the right edge of the tile. + #[inline] + pub const fn intersects_right(&self) -> bool { + (self.intersection_mask() & R) != 0 + } + + /// Return the `u64` representation of this tile. + /// + /// This is the u64 interpretation of `(y, x, packed_winding_line_idx)` where `y` is the + /// most-significant part of the number and `packed_winding_line_idx` the least significant. + #[inline(always)] + const fn to_bits(self) -> u64 { + // Note that for correct rendering, tiles only need to be sorted on `(y, x)`. Sorting on + // the line index in addition to the coordinate improves data locality in strip rendering. + // This is trading off increased sorting time for decreased strip rendering time. How the + // trade-off falls is scene-dependent. + // + // This operation compiles to a no-op: `Tile`'s field order is such that this is exactly + // the in-memory representation. + ((self.y as u64) << 48) | ((self.x as u64) << 32) | self.packed_winding_line_idx as u64 + } + + /// Whether a tile is a sentinel tile + // + // A tile produced organically by a make_tiles call can never have this coordinate because of + // the division by tile size on creation, so checking on x is sufficient to identify it. + #[inline(always)] + pub const fn is_sentinel(&self) -> bool { + self.x == u16::MAX + } +} + +impl PartialEq for Tile { + #[inline(always)] + fn eq(&self, other: &Self) -> bool { + self.to_bits() == other.to_bits() + } +} + +impl Ord for Tile { + #[inline(always)] + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.to_bits().cmp(&other.to_bits()) + } +} + +impl PartialOrd for Tile { + #[inline(always)] + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Eq for Tile {} + +/// Handles the tiling of paths. +#[derive(Clone, Debug)] +pub struct Tiles { + tile_buf: Vec, + level: Level, + sorted: bool, + /// Auxiliary data tracking row windings and active rows for early culling. + pub windings: CulledWindings, +} + +impl Tiles { + /// Create a new tiles container. + pub fn new(level: Level, height: u16) -> Self { + Self { + tile_buf: vec![], + level, + sorted: false, + windings: CulledWindings::new(height), + } + } + + /// Get the number of tiles in the container. + pub fn len(&self) -> u32 { + self.tile_buf.len() as u32 + } + + /// Returns `true` if the container has no tiles. + pub fn is_empty(&self) -> bool { + self.tile_buf.is_empty() + } + + /// Returns `true` if any geometry was early-culled outside the viewport. + pub fn has_culled_tiles(&self) -> bool { + self.windings.culled + } + + /// Reset the tiles' container. + pub fn reset(&mut self) { + self.windings.reset(); + self.tile_buf.clear(); + self.sorted = false; + } + + /// Sort the tiles in the container. + pub fn sort_tiles(&mut self) { + self.sorted = true; + // To enable auto-vectorization. + self.level.dispatch(|_| self.tile_buf.sort_unstable()); + } + + /// Get the tile at a certain index. + /// + /// Panics if the container hasn't been sorted before. + #[inline] + pub fn get(&self, index: u32) -> &Tile { + assert!( + self.sorted, + "attempted to call `get` before sorting the tile container." + ); + + &self.tile_buf[index as usize] + } + + /// Iterate over the tiles in sorted order. + /// + /// Panics if the container hasn't been sorted before. + #[inline] + pub fn iter(&self) -> impl Iterator { + assert!( + self.sorted, + "attempted to call `iter` before sorting the tile container." + ); + + self.tile_buf.iter() + } + + /// Generates tile commands for Analytic Anti-Aliasing rasterization. Unlike the MSAA path, this + /// function performs "coarse binning" to simply identify every tile a line segment traverses. + /// It encodes the line index and winding direction, delegating the precise calculation of pixel + /// coverage to `strip::render`. + pub fn make_tiles_analytic_aa( + &mut self, + level: Level, + lines: &[Line], + width: u16, + height: u16, + ) -> bool { + dispatch!(level, simd => self.make_tiles_analytic_aa_impl::<_>( + simd, + lines, + width, + height, + )) + } + + fn make_tiles_analytic_aa_impl( + &mut self, + s: S, + lines: &[Line], + width: u16, + height: u16, + ) -> bool { + self.reset(); + + if width == 0 || height == 0 { + return self.windings.culled; + } + + debug_assert!( + lines.len() <= MAX_LINES_PER_PATH as usize, + "Max. number of lines per path exceeded. Max is {}, got {}.", + MAX_LINES_PER_PATH, + lines.len() + ); + + let tile_columns = width.div_ceil(Tile::WIDTH); + let tile_rows = height.div_ceil(Tile::HEIGHT); + + let px_top = f32x4::from_slice(s, &[0.0, 1.0, 2.0, 3.0]); + let px_bottom = px_top + f32x4::splat(s, 1.0); + let simd_zero = f32x4::splat(s, 0.0); + let tile_height_f32 = Tile::HEIGHT as f32; + + for (line_idx, line) in lines.iter().take(MAX_LINES_PER_PATH as usize).enumerate() { + let line_idx = line_idx as u32; + + let p0_x = line.p0.x / f32::from(Tile::WIDTH); + let p0_y = line.p0.y / f32::from(Tile::HEIGHT); + let p1_x = line.p1.x / f32::from(Tile::WIDTH); + let p1_y = line.p1.y / f32::from(Tile::HEIGHT); + + let (line_left_x, line_right_x) = if p0_x < p1_x { + (p0_x, p1_x) + } else { + (p1_x, p0_x) + }; + + // Lines whose left-most endpoint exceed the right edge of the viewport are culled + if line_left_x > tile_columns as f32 { + continue; + } + + let (line_top_y, line_top_x, line_bottom_y, line_bottom_x) = if p0_y < p1_y { + (p0_y, p0_x, p1_y, p1_x) + } else { + (p1_y, p1_x, p0_y, p0_x) + }; + + // The `as u16` casts here intentionally clamp negative coordinates to 0. + let y_top_tiles = (line_top_y as u16).min(tile_rows); + let line_bottom_y_ceil = line_bottom_y.ceil(); + let y_bottom_tiles = (line_bottom_y_ceil as u16).min(tile_rows); + + // If y_top_tiles == y_bottom_tiles, then the line is either completely above or below + // the viewport OR it is perfectly horizontal and aligned to the tile grid, contributing + // no winding. In either case, it should be culled. + if y_top_tiles >= y_bottom_tiles { + // Technically, the `>` part of the `>=` is unnecessary due to clamping, but this + // gives stronger signal + continue; + } + + let dir = if p0_y >= p1_y { 1 } else { -1 }; + let f_dir = dir as f32; + let f_dir_v = f32x4::splat(s, f_dir); + + macro_rules! calc_fractional_coverage { + ($y_idx:expr, $segment_top_y:expr, $segment_bottom_y:expr) => {{ + let y_idx_f32 = f32::from($y_idx); + let local_y_start = ($segment_top_y - y_idx_f32) * tile_height_f32; + let local_y_end = ($segment_bottom_y - y_idx_f32) * tile_height_f32; + + let start_v = f32x4::splat(s, local_y_start); + let end_v = f32x4::splat(s, local_y_end); + + (px_bottom.min(end_v) - px_top.max(start_v)).max(simd_zero) + }}; + } + + // Lines fully to the left of the viewport are not visible but still produce winding + // which we record here and forward to the rendering stage. + if line_right_x < 0.0 { + let is_start_culled = line_top_y < 0.0; + + // This branch is for handling the "start" of the line. In case + // the line reaches above the viewport, we are already in the + // middle so we can skip that part. + if !is_start_culled { + self.windings.mark_row_active(y_top_tiles as usize); + + // Note: In theory, == should be enough, but just as + // additional safety against numerical precision errors we + // use <=. + let at_top_of_tile = line_top_y <= f32::from(y_top_tiles); + if at_top_of_tile { + self.windings.coarse[y_top_tiles as usize] += dir; + } + + let fractional_coverage = + calc_fractional_coverage!(y_top_tiles, line_top_y, line_bottom_y); + let target_row = &mut self.windings.partial[y_top_tiles as usize]; + let current = f32x4::from_slice(s, target_row); + + // See comment below on the double counting risk! + let double_count = if at_top_of_tile { + f_dir_v + } else { + f32x4::splat(s, 0.0) + }; + let next = fractional_coverage.mul_add(f_dir_v, current - double_count); + next.store_slice(target_row); + } + + let y_start_middle = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + let line_bottom_floor = line_bottom_y.floor(); + let y_end_middle = (line_bottom_floor as u16).min(tile_rows); + + for y_idx in y_start_middle..y_end_middle { + self.windings.coarse[y_idx as usize] += dir; + } + self.windings + .mark_row_range_active(y_start_middle as usize, y_end_middle as usize); + + if line_bottom_y != line_bottom_floor + && y_end_middle < tile_rows + // Prevent double-processing, unless the start was off-screen and hasn't been + // handled yet. + && (is_start_culled || y_end_middle != y_top_tiles) + { + self.windings.mark_row_active(y_end_middle as usize); + // Ends implicitly cross the top. + self.windings.coarse[y_end_middle as usize] += dir; + let fractional_coverage = + calc_fractional_coverage!(y_end_middle, line_top_y, line_bottom_y); + let target_row = &mut self.windings.partial[y_end_middle as usize]; + let current = f32x4::from_slice(s, target_row); + // Subtract the inverse direction to avoid double counting with the coarse winding. + let next = fractional_coverage.mul_add(f_dir_v, current - f_dir_v); + next.store_slice(target_row); + } + + self.windings.culled = true; + continue; + } + + // Get tile coordinates for start/end points, use i32 to preserve negative coordinates. + let p0_tile_x = line_top_x.floor() as i32; + let p0_tile_y = line_top_y.floor() as i32; + let p1_tile_x = line_bottom_x.floor() as i32; + let p1_tile_y = line_bottom_y.floor() as i32; + + // Special-case out lines which are fully contained within a tile. + let not_same_tile = p0_tile_y != p1_tile_y || p0_tile_x != p1_tile_x; + if not_same_tile { + // Case vertical lines: By definition, these cannot be horizontally crossing, and + // thus require no additional left-edge culling handling. + if line_left_x == line_right_x { + let x = (line_left_x as u16).min(tile_columns.saturating_sub(1)); + + // Row Start, not culled. + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let winding = + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT; + let tile = Tile::new_clamped(x, y_top_tiles, line_idx, winding); + self.tile_buf.push(tile); + } + + // Middle + // If the start was culled, the first tile inside the viewport is a middle. + let y_start = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + + for y_idx in y_start..y_bottom_tiles { + let tile = Tile::new_clamped(x, y_idx, line_idx, W); + self.tile_buf.push(tile); + } + } else { + // General case, any line which crosses more than one tile and is not vertical. + let dx = p1_x - p0_x; + let dy = p1_y - p0_y; + let x_slope = dx / dy; + let dx_dir = (line_bottom_x >= line_top_x) as u32; + let not_dx_dir = dx_dir ^ 1; + + let w_start_base = dx_dir << WINDING_SHIFT; + let w_end_base = not_dx_dir << WINDING_SHIFT; + + let mut push_row = { + #[inline(always)] + |y_idx: u16, + row_top_y: f32, + row_bottom_y: f32, + w_start: u32, + w_end: u32, + w_single: u32| { + let row_top_x = p0_x + (row_top_y - p0_y) * x_slope; + let row_bottom_x = p0_x + (row_bottom_y - p0_y) * x_slope; + + let row_left_x = f32::min(row_top_x, row_bottom_x).max(line_left_x); + let row_right_x = f32::max(row_top_x, row_bottom_x).min(line_right_x); + + if row_left_x < 0.0 { + self.windings.culled = true; + + if row_right_x < 0.0 { + // Although the line may cross the left edge, the rightmost point in + // this row may still be fully left of the viewport. In this case, + // record the winding and emit no tiles. + self.windings.mark_row_active(y_idx as usize); + + let crosses_top = (w_single & W) != 0; + if crosses_top { + self.windings.coarse[y_idx as usize] += dir; + } + + let fractional_coverage = + calc_fractional_coverage!(y_idx, row_top_y, row_bottom_y); + let target_row = &mut self.windings.partial[y_idx as usize]; + let current = f32x4::from_slice(s, target_row); + + let double_count = if crosses_top { + f_dir_v + } else { + f32x4::splat(s, 0.0) + }; + let next = fractional_coverage + .mul_add(f_dir_v, current - double_count); + next.store_slice(target_row); + + return; + } else { + // The line crosses into the viewport in this row. Record only the + // fractional portion of the winding, as the coarse winding will + // naturally get included by the clamped tile logic! + let y_slope = dy / dx; + let y_intersect = row_top_y - (row_top_x * y_slope); + + let (off_screen_top_y, off_screen_bottom_y) = if row_top_x < 0.0 + { + (row_top_y, f32::min(row_bottom_y, y_intersect)) + } else { + (f32::max(row_top_y, y_intersect), row_bottom_y) + }; + + if off_screen_top_y < off_screen_bottom_y { + self.windings.mark_row_active(y_idx as usize); + let fractional_coverage = calc_fractional_coverage!( + y_idx, + off_screen_top_y, + off_screen_bottom_y + ); + let target_row = &mut self.windings.partial[y_idx as usize]; + let current = f32x4::from_slice(s, target_row); + let next = fractional_coverage.mul_add(f_dir_v, current); + next.store_slice(target_row); + } + } + } + + let x_start = row_left_x as u16; + let x_end = (row_right_x as u16).min(tile_columns.saturating_sub(1)); + + if x_start <= x_end { + let winding = if x_start == x_end { w_single } else { w_start }; + + self.tile_buf + .push(Tile::new(x_start, y_idx, line_idx, winding)); + + for x_idx in x_start.saturating_add(1)..x_end { + self.tile_buf.push(Tile::new(x_idx, y_idx, line_idx, 0)); + } + + if x_start < x_end { + self.tile_buf.push(Tile::new(x_end, y_idx, line_idx, w_end)); + } + } + } + }; + + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let y = f32::from(y_top_tiles); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + let mask = ((y >= line_top_y) as u32) << WINDING_SHIFT; + push_row( + y_top_tiles, + line_top_y, + row_bottom_y, + w_start_base & mask, + w_end_base & mask, + W & mask, + ); + } + + let y_start = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + + for y_idx in y_start..y_bottom_tiles { + let y = f32::from(y_idx); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + push_row(y_idx, y, row_bottom_y, w_start_base, w_end_base, W); + } + } + } else { + // Case line is fully contained within a single tile: These also cannot cross edges! + let tile = Tile::new_clamped( + (line_left_x as u16).min(tile_columns + 1), + y_top_tiles, + line_idx, + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT, + ); + self.tile_buf.push(tile); + } + } + + self.windings.culled + } + + /// Generates tile commands for MSAA (Multisample Anti-Aliasing) rasterization. + /// + /// [ Architecture & Watertightness ] + /// The primary goal of this function is to establish a source of "ground truth" for line-tile + /// intersections. Because the downstream rasterization (MSAA) occurs in parallel, it is + /// critical that intersections are "watertight." If Thread A handles Tile (0,0) and Thread B + /// handles Tile (1,0), they must agree exactly on whether a line crosses the shared edge. + /// + /// While calculating exact intersection coordinates here is feasible, it is computationally + /// expensive. Instead, we defer the heavy math to the GPU/rasterizer and produce a lightweight + /// Intersection Bitmask. This mask unambiguously defines which edges of a tile a line segment + /// touches or crosses. + /// + /// [ The Intersection Bitmask (5 bits) ] + /// The bitmask encodes winding information and edge intersections. A line is said to + /// "intersect" an edge if it touches that edge AND continues into the neighboring tile. + /// + /// Bit representation: + /// Bit: 4 | 3 | 2 | 1 | 0 + /// Val: W | R | L | B | T + /// + /// - W (Winding): Tracks whether the line touched the top edge of the tile. + /// - R/L/B/T: Right, Left, Bottom, and Top edge intersections. + pub fn make_tiles_msaa(&mut self, lines: &[Line], width: u16, height: u16) { + self.reset(); + + if width == 0 || height == 0 { + return; + } + + debug_assert!( + lines.len() <= MAX_LINES_PER_PATH as usize, + "Max. number of lines per path exceeded. Max is {}, got {}.", + MAX_LINES_PER_PATH, + lines.len() + ); + + let tile_columns = width.div_ceil(Tile::WIDTH); + let tile_rows = height.div_ceil(Tile::HEIGHT); + + for (line_idx, line) in lines.iter().take(MAX_LINES_PER_PATH as usize).enumerate() { + let line_idx = line_idx as u32; + + let p0_x = line.p0.x / f32::from(Tile::WIDTH); + let p0_y = line.p0.y / f32::from(Tile::HEIGHT); + let p1_x = line.p1.x / f32::from(Tile::WIDTH); + let p1_y = line.p1.y / f32::from(Tile::HEIGHT); + + let (line_left_x, line_right_x) = if p0_x < p1_x { + (p0_x, p1_x) + } else { + (p1_x, p0_x) + }; + + // Lines whose left-most endpoint exceed the right edge of the viewport are culled + if line_left_x > tile_columns as f32 { + continue; + } + + let (line_top_y, line_top_x, line_bottom_y, line_bottom_x) = if p0_y < p1_y { + (p0_y, p0_x, p1_y, p1_x) + } else { + (p1_y, p1_x, p0_y, p0_x) + }; + + // The `as u16` casts here intentionally clamp negative coordinates to 0. + let y_top_tiles = (line_top_y as u16).min(tile_rows); + let line_bottom_y_ceil = line_bottom_y.ceil(); + let y_bottom_tiles = (line_bottom_y_ceil as u16).min(tile_rows); + + // If y_top_tiles == y_bottom_tiles, then the line is either completely above or below + // the viewport OR it is perfectly horizontal and aligned to the tile grid, contributing + // no winding. In either case, it should be culled. + if y_top_tiles >= y_bottom_tiles { + continue; + } + + // Get tile coordinates for start/end points, use i32 to preserve negative coordinates + let p0_tile_x = line_top_x.floor() as i32; + let p0_tile_y = line_top_y.floor() as i32; + let p1_tile_x = line_bottom_x.floor() as i32; + + let p1_tile_y = if line_bottom_y == line_bottom_y_ceil { + line_bottom_y as i32 - 1 + } else { + line_bottom_y.floor() as i32 + }; + + // Special-case out lines which are fully contained within a tile. + let not_same_tile = p0_tile_y != p1_tile_y || p0_tile_x != p1_tile_x; + if not_same_tile { + // For ease of logic, special-case purely vertical tiles. + if line_left_x == line_right_x { + let x = (line_left_x as u16).min(tile_columns.saturating_sub(1)); + + // Row Start, not culled. + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let winding = + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT; + let intersection_mask = B | winding; + let tile = Tile::new_clamped(x, y_top_tiles, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + + // Middle + // If the start was culled, the first tile inside the viewport is a middle. + let y_start = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + let line_bottom_floor = line_bottom_y.floor(); + let y_end_idx = (line_bottom_floor as u16).min(tile_rows); + + if y_start < y_end_idx { + let y_last = y_end_idx - 1; + for y_idx in y_start..y_last { + let intersection_mask = W | B | T; + let tile = Tile::new_clamped(x, y_idx, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + + // Perfect touching B case. + { + let is_end_tile = ((y_last as i32) == p1_tile_y) as u32; + let intersection_mask = W | T | ((1 ^ is_end_tile) << BOT_SHIFT); + let tile = Tile::new_clamped(x, y_last, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + } + + // Row End, handle the final tile (y_end_idx), but *only* if the line does + // not perfectly end on the top edge of the tile. In the case that it does, + // it gets handled by the middle logic above. + if line_bottom_y != line_bottom_floor && y_end_idx < tile_rows { + let intersection_mask = W | T; + let tile = Tile::new_clamped(x, y_end_idx, line_idx, intersection_mask); + self.tile_buf.push(tile); + } + } else { + let dx = p1_x - p0_x; + let dy = p1_y - p0_y; + let x_slope = dx / dy; + let dx_dir = (line_bottom_x >= line_top_x) as u32; + let not_dx_dir = dx_dir ^ 1; + + let w_start_base = dx_dir << WINDING_SHIFT; + let w_end_base = not_dx_dir << WINDING_SHIFT; + + // Check if the line is fully within the horizontal viewport bounds. If it is, + // we can skip the min/max clamping per row. + let min_x = p0_x.min(p1_x); + let max_x = p0_x.max(p1_x); + // Note: We use >= on the right edge to ensure strictly safe integer truncation + let needs_clamping = min_x < line_left_x || max_x >= line_right_x; + + // Handles the bitmask logic for start/end tiles. Invariant to clamping. + macro_rules! push_edge { + ($x:expr, $y:expr, $row_top_x:expr, $row_bottom_x:expr, + $canonical_start:expr, $canonical_end:expr, $winding_input:expr, + $check_s:tt, $check_e:expr) => {{ + let x_idx = $x; + + let unc_row_start = (x_idx as i32 == $canonical_start) as u32; + let unc_row_end = (x_idx == $canonical_end) as u32; + + let canonical_row_start = + (dx_dir & unc_row_start) | (not_dx_dir & unc_row_end); + let canonical_row_end = + (not_dx_dir & unc_row_start) | (dx_dir & unc_row_end); + + let start_tile = if $check_s { + ((x_idx as i32 == p0_tile_x) && ($y as i32 == p0_tile_y)) as u32 + } else { + 0 + }; + + let end_tile = if $check_e { + ((x_idx as i32 == p1_tile_x) && ($y as i32 == p1_tile_y)) as u32 + } else { + 0 + }; + + let mut mask = $winding_input; + + // Entrant/Exit + mask |= canonical_row_start & (1 ^ start_tile); + mask |= (1 ^ canonical_row_start) << not_dx_dir << LEFT_SHIFT; + mask |= (canonical_row_end & (1 ^ end_tile)) << BOT_SHIFT; + mask |= (1 ^ canonical_row_end) << dx_dir << LEFT_SHIFT; + + // Corner + let x_left_f = x_idx as f32; + let x_right_f = (x_idx + 1) as f32; + let trc = (($row_top_x == x_right_f) as u32) & (1 ^ start_tile); + let tlc = (($row_top_x == x_left_f) as u32) & (1 ^ start_tile); + let brc = (($row_bottom_x == x_right_f) as u32) & (1 ^ end_tile); + let blc = (($row_bottom_x == x_left_f) as u32) & (1 ^ end_tile); + // Top left is handled specially + let tie_break = tlc & (canonical_row_start ^ 1); + + mask |= (tie_break | blc) << LEFT_SHIFT; + mask |= (trc | brc) << RIGHT_SHIFT; + mask &= !(tie_break | trc); + mask &= !((blc | brc) << BOT_SHIFT); + + self.tile_buf.push(Tile::new(x_idx, $y, line_idx, mask)); + }}; + } + + // Handles row geometry and clamping logic. + macro_rules! process_row { + ($y_idx:expr, $row_top_y:expr, $row_bottom_y:expr, $w_mask:expr, + $check_s:tt, $check_e:tt, $clamped:tt) => {{ + let row_top_x = p0_x + ($row_top_y - p0_y) * x_slope; + let row_bottom_x = p0_x + ($row_bottom_y - p0_y) * x_slope; + + let (row_left_x, row_right_x, x_end) = if $clamped { + let lx = f32::min(row_top_x, row_bottom_x).max(line_left_x); + let rx = f32::max(row_top_x, row_bottom_x).min(line_right_x); + let xe = (rx as u16).min(tile_columns.saturating_sub(1)); + (lx, rx, xe) + } else { + let lx = f32::min(row_top_x, row_bottom_x); + let rx = f32::max(row_top_x, row_bottom_x); + let xe = rx as u16; // Safe because we checked bounds earlier + (lx, rx, xe) + }; + + let canonical_x_start = row_left_x.floor() as i32; + let canonical_x_end = row_right_x as u16; + let x_start = row_left_x as u16; + + if x_start <= x_end { + let is_single = (x_start == x_end) as u32; + let w_left = (w_start_base | (is_single << 4)) & $w_mask; + + push_edge!( + x_start, + $y_idx, + row_top_x, + row_bottom_x, + canonical_x_start, + canonical_x_end, + w_left, + $check_s, + $check_e + ); + + for x_idx in x_start.saturating_add(1)..x_end { + self.tile_buf + .push(Tile::new(x_idx, $y_idx, line_idx, R | L)); + } + + if x_start < x_end { + let w_right = w_end_base & $w_mask; + push_edge!( + x_end, + $y_idx, + row_top_x, + row_bottom_x, + canonical_x_start, + canonical_x_end, + w_right, + $check_s, + $check_e + ); + } + } + }}; + } + + // Central macro + macro_rules! run_loops { + ($clamped:tt) => {{ + // Top Row + let is_start_culled = line_top_y < 0.0; + if !is_start_culled { + let y = f32::from(y_top_tiles); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + let mask = ((y >= line_top_y) as u32) << WINDING_SHIFT; + process_row!( + y_top_tiles, + line_top_y, + row_bottom_y, + mask, + true, + true, + $clamped + ); + } + + let y_start_middle = if is_start_culled { + y_top_tiles + } else { + y_top_tiles + 1 + }; + let line_bottom_floor = line_bottom_y.floor(); + let y_end_middle = (line_bottom_floor as u16).min(tile_rows); + let has_separate_bottom_row = line_bottom_y != line_bottom_floor + && y_end_middle < tile_rows + && (is_start_culled || y_end_middle != y_top_tiles); + + if y_start_middle < y_end_middle { + for y_idx in y_start_middle..y_end_middle { + let y = f32::from(y_idx); + let row_bottom_y = (y + 1.0).min(line_bottom_y); + let is_last_middle = y_idx == y_end_middle - 1; + let check_end = is_last_middle && !has_separate_bottom_row; + + process_row!( + y_idx, + y, + row_bottom_y, + u32::MAX, + false, + check_end, + $clamped + ); + } + } + + // Bottom Row + if has_separate_bottom_row { + let y_idx = y_end_middle; + let y = f32::from(y_idx); + process_row!( + y_idx, + y, + line_bottom_y, + u32::MAX, + false, + true, + $clamped + ); + } + }}; + } + + if needs_clamping { + run_loops!(true); + } else { + run_loops!(false); + } + } + } else { + // Case: Line is fully contained within a single tile. + let tile = Tile::new_clamped( + (line_left_x as u16).min(tile_columns + 1), + y_top_tiles, + line_idx, + ((f32::from(y_top_tiles) >= line_top_y) as u32) << WINDING_SHIFT, + ); + self.tile_buf.push(tile); + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::flatten::{FlattenCtx, Line, Point, fill}; + use crate::geometry::RectU16; + use crate::kurbo::{Affine, BezPath}; + use crate::tile::CulledWindings; + use crate::tile::{B, L, R, T, Tile, Tiles, W}; + use fearless_simd::Level; + use std::vec::Vec; + + const VIEW_DIM: u16 = 100; + const F_V_DIM: f32 = VIEW_DIM as f32; + + impl Tiles { + fn assert_tiles_match( + &mut self, + lines: &[Line], + width: u16, + height: u16, + expected: &[Tile], + ) { + self.make_tiles_msaa(lines, width, height); + assert_eq!(self.tile_buf, expected, "MSAA: Tile buffer mismatch"); + + self.make_tiles_analytic_aa(Level::baseline(), lines, width, height); + check_analytic_aa_matches(&self.tile_buf, expected); + } + } + + fn check_analytic_aa_matches(actual: &[Tile], expected: &[Tile]) { + assert_eq!( + actual.len(), + expected.len(), + "Analytic AA: Tile count mismatch." + ); + + for (i, (got, want)) in actual.iter().zip(expected.iter()).enumerate() { + assert_eq!(got.x, want.x, "Analytic AA: Tile[{}] X mismatch", i); + assert_eq!(got.y, want.y, "Analytic AA: Tile[{}] Y mismatch", i); + assert_eq!( + got.line_idx(), + want.line_idx(), + "Analytic AA: Tile[{}] Line Index mismatch", + i + ); + + let got_winding = got.packed_winding_line_idx & W; + let want_winding = want.packed_winding_line_idx & W; + assert_eq!( + got_winding, want_winding, + "Analytic AA: Tile[{}] Winding mismatch", + i + ); + } + } + + //============================================================================================== + // Culled Lines + //============================================================================================== + #[test] + fn cull_sloped_outside_lines() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 3.0, y: -1.0 }, + }, + Line { + p0: Point { x: 1.0, y: -11.0 }, + p1: Point { x: 3.0, y: -1.0 }, + }, + Line { + p0: Point { + x: F_V_DIM + 1.0, + y: 50.0, + }, + p1: Point { + x: F_V_DIM + 3.0, + y: 70.0, + }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 3.0, + y: F_V_DIM + 7.0, + }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 3.0, + y: F_V_DIM + 13.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn sloped_line_crossing_top() { + let lines = [ + Line { + p0: Point { x: -2.0, y: -3.0 }, + p1: Point { x: 2.0, y: 1.0 }, + }, + Line { + p0: Point { x: 6.0, y: -1.0 }, + p1: Point { x: 5.0, y: 2.0 }, + }, + Line { + p0: Point { x: 9.0, y: -10.0 }, + p1: Point { x: 10.0, y: 3.0 }, + }, + Line { + p0: Point { x: 2.0, y: 1.0 }, + p1: Point { x: -2.0, y: -3.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | T), + Tile::new(1, 0, 1, W | T), + Tile::new(2, 0, 2, W | T), + Tile::new(0, 0, 3, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_bot() { + let lines = [ + Line { + p0: Point { + x: 5.0, + y: F_V_DIM + 3.0, + }, + p1: Point { + x: 6.0, + y: F_V_DIM - 2.0, + }, + }, + Line { + p0: Point { + x: 10.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 9.0, + y: F_V_DIM - 1.0, + }, + }, + Line { + p0: Point { + x: 2.0, + y: F_V_DIM - 2.0, + }, + p1: Point { + x: 3.0, + y: F_V_DIM + 3.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 24, 0, B), + Tile::new(2, 24, 1, B), + Tile::new(0, 24, 2, B), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_top_multi_tile() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -5.0 }, + p1: Point { x: 6.0, y: 7.0 }, + }, + Line { + p0: Point { x: 2.5, y: -10.0 }, + p1: Point { x: 3.5, y: 6.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | T | R), + Tile::new(1, 0, 0, L | B), + Tile::new(1, 1, 0, W | T), + Tile::new(0, 0, 1, W | T | B), + Tile::new(0, 1, 1, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_bot_multi_tile() { + let lines = [ + Line { + p0: Point { + x: 12.0, + y: F_V_DIM + 10.0, + }, + p1: Point { x: 2.0, y: 94.0 }, + }, + Line { + p0: Point { + x: 1.5, + y: F_V_DIM + 5.0, + }, + p1: Point { x: 3.5, y: 94.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 23, 0, B), + Tile::new(0, 24, 0, W | T | R), + Tile::new(1, 24, 0, B | L), + Tile::new(0, 23, 1, B), + Tile::new(0, 24, 1, W | T | B), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_right() { + let lines = [ + Line { + p0: Point { x: 97.0, y: 1.0 }, + p1: Point { + x: F_V_DIM + 1.0, + y: 2.0, + }, + }, + Line { + p0: Point { x: 93.0, y: 1.0 }, + p1: Point { + x: F_V_DIM + 5.0, + y: 2.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(24, 0, 0, R), + Tile::new(23, 0, 1, R), + Tile::new(24, 0, 1, R | L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_line_crossing_left() { + let lines = [ + Line { + p0: Point { x: -5.0, y: 1.0 }, + p1: Point { x: 1.0, y: 2.0 }, + }, + Line { + p0: Point { x: -5.0, y: 1.0 }, + p1: Point { x: 5.0, y: 2.0 }, + }, + Line { + p0: Point { x: -5.0, y: 1.0 }, + p1: Point { x: 13.0, y: 9.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, L), + Tile::new(0, 0, 1, L | R), + Tile::new(1, 0, 1, L), + Tile::new(0, 0, 2, L | B), + Tile::new(0, 1, 2, W | R | T), + Tile::new(1, 1, 2, R | L), + Tile::new(2, 1, 2, L | B), + Tile::new(2, 2, 2, W | R | T), + Tile::new(3, 2, 2, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_above_viewport() { + let lines = [Line { + p0: Point { x: 10.0, y: -5.0 }, + p1: Point { x: 90.0, y: -5.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn horizontal_line_below_viewport() { + let lines = [Line { + p0: Point { + x: 10.0, + y: F_V_DIM + 5.0, + }, + p1: Point { + x: 90.0, + y: F_V_DIM + 5.0, + }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn horizontal_line_crossing_left_viewport() { + let lines = [Line { + p0: Point { x: -10.0, y: 10.0 }, + p1: Point { x: 10.0, y: 10.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 2, 0, L | R), + Tile::new(1, 2, 0, L | R), + Tile::new(2, 2, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_crossing_right_viewport() { + let lines = [Line { + p0: Point { + x: F_V_DIM - 5.0, + y: 10.0, + }, + p1: Point { + x: F_V_DIM + 5.0, + y: 10.0, + }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(23, 2, 0, R), Tile::new(24, 2, 0, L | R)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_lines_outside_viewport() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -5.0 }, + p1: Point { x: 1.0, y: -1.0 }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM + 1.0, + }, + p1: Point { + x: 1.0, + y: F_V_DIM + 5.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]); + } + + #[test] + fn vertical_path_on_the_right_of_viewport() { + const VIEWPORT_WIDTH: u16 = 10; + const VIEWPORT_HEIGHT: u16 = 10; + + let path = BezPath::from_svg("M261,0 L78848,0 L78848,4 L261,4 Z").unwrap(); + let mut line_buf: Vec = Vec::new(); + fill( + Level::try_detect().unwrap_or(Level::baseline()), + &path, + Affine::IDENTITY, + &mut line_buf, + &mut FlattenCtx::default(), + RectU16::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT), + ); + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.assert_tiles_match(&line_buf, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, &[]); + } + + #[test] + fn vertical_line_crossing_top_viewport() { + let lines = [ + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 1.0, y: 3.0 }, + }, + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 1.0, y: 7.0 }, + }, + Line { + p0: Point { x: 1.0, y: -7.0 }, + p1: Point { x: 1.0, y: 8.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | T), + Tile::new(0, 0, 1, W | B | T), + Tile::new(0, 1, 1, W | T), + Tile::new(0, 0, 2, W | B | T), + Tile::new(0, 1, 2, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_crossing_bot_viewport() { + let lines = [ + Line { + p0: Point { + x: 1.0, + y: F_V_DIM - 1.0, + }, + p1: Point { + x: 1.0, + y: F_V_DIM + 5.0, + }, + }, + Line { + p0: Point { + x: 1.0, + y: F_V_DIM - 5.0, + }, + p1: Point { + x: 1.0, + y: F_V_DIM + 5.0, + }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 24, 0, B), + Tile::new(0, 23, 1, B), + Tile::new(0, 24, 1, W | T | B), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn clip_top_left_corner() { + let lines = [Line { + p0: Point { x: -1.0, y: 2.0 }, + p1: Point { x: 2.0, y: -1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W | L | T)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn clip_bottom_right_corner() { + let lines = [Line { + p0: Point { + x: F_V_DIM + 1.0, + y: F_V_DIM - 2.0, + }, + p1: Point { + x: F_V_DIM - 2.0, + y: F_V_DIM + 1.0, + }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(24, 24, 0, R | B)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // Axis-aligned lines + //============================================================================================== + #[test] + fn horizontal_line_left_to_right_three_tile() { + let lines = [Line { + p0: Point { x: 1.5, y: 1.0 }, + p1: Point { x: 8.5, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_right_to_left_three_tile() { + let lines = [Line { + p0: Point { x: 8.5, y: 1.0 }, + p1: Point { x: 1.5, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn horizontal_line_multi_tile() { + let lines = [Line { + p0: Point { x: 1.5, y: 1.0 }, + p1: Point { x: 12.5, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, R | L), + Tile::new(3, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_down_three_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.5 }, + p1: Point { x: 1.0, y: 8.5 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_down_multi_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 1.0, y: 13.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T | B), + Tile::new(0, 3, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_up_three_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 13.0 }, + p1: Point { x: 1.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T | B), + Tile::new(0, 3, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_up_multi_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 8.5 }, + p1: Point { x: 1.0, y: 1.5 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, B), + Tile::new(0, 1, 0, W | T | B), + Tile::new(0, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + // Exclusive to the bottom edge, no P required. + #[test] + fn vertical_line_touching_bot() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 1.0, y: 8.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, B), Tile::new(0, 1, 0, W | T)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn vertical_line_touching_top() { + let lines = [Line { + p0: Point { x: 1.0, y: 0.0 }, + p1: Point { x: 1.0, y: 7.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W | B), Tile::new(0, 1, 0, W | T)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // Sloped Lines + //============================================================================================== + #[test] + fn top_left_to_bottom_right() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 11.0, y: 9.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, L | B), + Tile::new(1, 1, 0, W | R | T), + Tile::new(2, 1, 0, L | B), + Tile::new(2, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn bottom_right_to_top_left() { + let lines = [Line { + p0: Point { x: 11.0, y: 9.0 }, + p1: Point { x: 1.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, L | B), + Tile::new(1, 1, 0, W | R | T), + Tile::new(2, 1, 0, L | B), + Tile::new(2, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn bottom_left_to_top_right() { + let lines = [Line { + p0: Point { x: 2.0, y: 11.0 }, + p1: Point { x: 14.0, y: 6.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(2, 1, 0, R | B), + Tile::new(3, 1, 0, L), + Tile::new(0, 2, 0, R), + Tile::new(1, 2, 0, R | L), + Tile::new(2, 2, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn top_right_to_bottom_left() { + let lines = [Line { + p0: Point { x: 14.0, y: 6.0 }, + p1: Point { x: 2.0, y: 11.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(2, 1, 0, R | B), + Tile::new(3, 1, 0, L), + Tile::new(0, 2, 0, R), + Tile::new(1, 2, 0, R | L), + Tile::new(2, 2, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn two_lines_in_single_tile() { + let lines = [ + Line { + p0: Point { x: 1.0, y: 3.0 }, + p1: Point { x: 3.0, y: 3.0 }, + }, + Line { + p0: Point { x: 3.0, y: 3.0 }, + p1: Point { x: 0.0, y: 1.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0), Tile::new(0, 0, 1, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_cross_corner() { + let lines = [Line { + p0: Point { x: 3.0, y: 5.0 }, + p1: Point { x: 5.0, y: 3.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 0, 0, L), + Tile::new(0, 1, 0, R), + Tile::new(1, 1, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_cross_corner_two() { + let lines = [Line { + p0: Point { x: 7.9, y: 7.9 }, + p1: Point { x: 0.1, y: 0.1 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, L), + Tile::new(1, 1, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_down_slope_tiles() { + let lines = [Line { + p0: Point { x: 5.0, y: 5.0 }, + p1: Point { x: 9.0, y: 9.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 1, 0, R), + Tile::new(2, 1, 0, L), + Tile::new(2, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_up_slope_tiles() { + let lines = [Line { + p0: Point { x: 5.0, y: 9.0 }, + p1: Point { x: 9.0, y: 5.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 1, 0, R | B), + Tile::new(2, 1, 0, L), + Tile::new(1, 2, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_down_one_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 4.0, y: 4.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W | R), Tile::new(1, 0, 0, L)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_up_one_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 4.0 }, + p1: Point { x: 4.0, y: 0.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, R), Tile::new(1, 0, 0, W | L)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_down_two_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 8.0, y: 8.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | R), + Tile::new(1, 0, 0, L), + Tile::new(1, 1, 0, W | R | T), + Tile::new(2, 1, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn diagonal_up_two_tile() { + let lines = [Line { + p0: Point { x: 0.0, y: 8.0 }, + p1: Point { x: 8.0, y: 0.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, W | L), + Tile::new(0, 1, 0, R), + Tile::new(1, 1, 0, W | L | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_ending_right() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 8.0, y: 2.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R), + Tile::new(1, 0, 0, R | L), + Tile::new(2, 0, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_touching_top() { + let lines = [Line { + p0: Point { x: 0.0, y: 8.0 }, + p1: Point { x: 4.0, y: 0.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, R | B), + Tile::new(1, 0, 0, W | L), + Tile::new(0, 1, 0, W | T), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn sloped_touching_bot() { + let lines = [Line { + p0: Point { x: 0.0, y: 0.0 }, + p1: Point { x: 4.0, y: 8.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [ + Tile::new(0, 0, 0, W | B), + Tile::new(0, 1, 0, W | R | T), + Tile::new(1, 1, 0, L), + ]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // Same Tile Cases + //============================================================================================== + #[test] + fn same_tile() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 3.0, y: 3.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_left() { + let lines = [Line { + p0: Point { x: 0.0, y: 1.0 }, + p1: Point { x: 3.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_top() { + let lines = [Line { + p0: Point { x: 1.0, y: 0.0 }, + p1: Point { x: 1.0, y: 3.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_right() { + let lines = [Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 4.0, y: 1.0 }, + }]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, R), Tile::new(1, 0, 0, L)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_bottom() { + let lines = [ + Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 1.0, y: 4.0 }, + }, + Line { + p0: Point { x: 1.0, y: 1.0 }, + p1: Point { x: 2.0, y: 4.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, 0), Tile::new(0, 0, 1, 0)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + #[test] + fn same_tile_top_left() { + let lines = [ + Line { + p0: Point { x: 0.0, y: 1.0 }, + p1: Point { x: 1.0, y: 0.0 }, + }, + Line { + p0: Point { x: 0.0, y: 0.0001 }, + p1: Point { x: 0.0001, y: 0.0 }, + }, + ]; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + let expected = [Tile::new(0, 0, 0, W), Tile::new(0, 0, 1, W)]; + + tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected); + } + + //============================================================================================== + // CulledWindings & Row Marking Logic + //============================================================================================== + #[test] + fn test_culled_windings_new_and_reset() { + let mut windings = CulledWindings::new(100); + assert_eq!(windings.partial.len(), 25); + assert_eq!(windings.coarse.len(), 25); + assert_eq!(windings.active.len(), 1); + + windings.coarse[0] = 1; + windings.active[0] = 0xFF; + windings.culled = true; + + windings.reset(); + assert_eq!(windings.coarse[0], 0); + assert_eq!(windings.active[0], 0); + + windings.coarse[0] = 1; + windings.active[0] = 0xFF; + windings.culled = false; + + windings.reset(); + assert_eq!(windings.coarse[0], 1); + assert_eq!(windings.active[0], 0xFF); + } + + #[test] + fn test_mark_row_active() { + let mut windings = CulledWindings::new(200); + windings.mark_row_active(0); + windings.mark_row_active(5); + windings.mark_row_active(31); + windings.mark_row_active(32); + assert_eq!(windings.active[0], (1 << 0) | (1 << 5) | (1 << 31)); + assert_eq!(windings.active[1], 1 << 0); + } + + #[test] + fn test_mark_row_range_single_word() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(5, 10); + let expected_mask = ((1_u32 << 5) - 1) << 5; + assert_eq!(windings.active[0], expected_mask); + assert_eq!(windings.active[1], 0); + } + + #[test] + fn test_mark_row_range_full_word() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(0, 32); + assert_eq!(windings.active[0], u32::MAX); + assert_eq!(windings.active[1], 0); + } + + #[test] + fn test_mark_row_range_spanning_two_words() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(30, 35); + assert_eq!(windings.active[0], (1 << 30) | (1 << 31)); + assert_eq!(windings.active[1], (1 << 0) | (1 << 1) | (1 << 2)); + } + + #[test] + fn test_mark_row_range_spanning_multiple_words() { + let mut windings = CulledWindings::new(500); + windings.mark_row_range_active(10, 80); + assert_eq!(windings.active[0], u32::MAX << 10); + assert_eq!(windings.active[1], u32::MAX); + assert_eq!(windings.active[2], (1 << 16) - 1); + assert_eq!(windings.active[3], 0); + } + + #[test] + fn test_mark_row_range_empty_or_invalid() { + let mut windings = CulledWindings::new(200); + windings.mark_row_range_active(10, 10); + windings.mark_row_range_active(15, 10); + assert_eq!(windings.active[0], 0); + assert_eq!(windings.active[1], 0); + } + + //============================================================================================== + // Miscellaneous Cases + //============================================================================================== + #[test] + // See https://github.com/LaurenzV/cpu-sparse-experiments/issues/46. + fn infinite_loop() { + let line = Line { + p0: Point { x: 22.0, y: 552.0 }, + p1: Point { x: 224.0, y: 388.0 }, + }; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.make_tiles_msaa(&[line], 600, 600); + tiles.make_tiles_analytic_aa(Level::baseline(), &[line], 600, 600); + } + + #[test] + // See https://github.com/linebender/vello/issues/1321 + fn overflow() { + let line = Line { + p0: Point { + x: 59.60001, + y: 40.78, + }, + p1: Point { + x: 520599.6, + y: 100.18, + }, + }; + + let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM); + tiles.make_tiles_analytic_aa(Level::baseline(), &[line], 200, 100); + tiles.make_tiles_msaa(&[line], 200, 100); + } + + #[test] + fn sort_test() { + let mut lines: Vec = Vec::new(); + let mut tiles = Tiles::new(Level::baseline(), VIEW_DIM); + + let step = 4.0; + let mut y = F_V_DIM - 10.0; + while y > 10.0 { + lines.push(Line { + p0: Point { + x: F_V_DIM - 10.0, + y, + }, + p1: Point { x: 10.0, y }, + }); + + lines.push(Line { + p0: Point { + x: F_V_DIM - 12.0, + y, + }, + p1: Point { x: 12.0, y }, + }); + + y -= step; + } + + tiles.make_tiles_msaa(&lines, VIEW_DIM, VIEW_DIM); + assert!(tiles.tile_buf.first().unwrap().y > tiles.tile_buf.last().unwrap().y); + tiles.sort_tiles(); + check_sorted(&tiles.tile_buf); + + tiles.make_tiles_analytic_aa(Level::baseline(), &lines, VIEW_DIM, VIEW_DIM); + assert!(tiles.tile_buf.first().unwrap().y > tiles.tile_buf.last().unwrap().y); + tiles.sort_tiles(); + check_sorted(&tiles.tile_buf); + } + + fn check_sorted(buf: &[Tile]) { + for i in 0..buf.len() - 1 { + let current = buf[i]; + let next = buf[i + 1]; + + if current.y > next.y { + panic!( + "Sort Failure [Y]: Tile[{}] (y={}) > Tile[{}] (y={})", + i, + current.y, + i + 1, + next.y + ); + } + + if current.y == next.y { + if current.x > next.x { + panic!( + "Sort Failure [X]: at Row y={}, Tile[{}] (x={}) > Tile[{}] (x={})", + current.y, + i, + current.x, + i + 1, + next.x + ); + } + + if current.x == next.x + && current.packed_winding_line_idx > next.packed_winding_line_idx + { + panic!( + "Sort Failure [Payload]: at {}x{}, Tile[{}] (val={}) > Tile[{}] (val={})", + current.x, + current.y, + i, + current.packed_winding_line_idx, + i + 1, + next.packed_winding_line_idx + ); + } + } + } + } +} diff --git a/vendor/vello_common/src/util.rs b/vendor/vello_common/src/util.rs new file mode 100644 index 0000000000..a2dce6ea57 --- /dev/null +++ b/vendor/vello_common/src/util.rs @@ -0,0 +1,126 @@ +// Copyright 2025 the Vello Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Utility functions. + +use crate::math::FloatExt; +use fearless_simd::{ + Bytes, Simd, SimdBase, SimdFloat, f32x16, u8x16, u8x32, u16x16, u16x32, u32x16, +}; +use peniko::kurbo::Affine; +#[cfg(not(feature = "std"))] +use peniko::kurbo::common::FloatFuncs as _; + +/// Convert f32x16 to u8x16. +/// +/// **Important note: The values need to be between 0.0 and 1.0, otherwise you might +/// get inconsistent results across different platforms.** +// We can't guarantee correctness for values < 0.0 due to a restriction in fearless_simd: +// https://github.com/linebender/fearless_simd/blob/3f4489389940b7c3c6ee1847a2d007a22494eeff/fearless_simd/src/generated/simd_types.rs#L1623 +#[inline(always)] +pub fn f32_to_u8(val: f32x16) -> u8x16 { + let simd = val.simd; + let converted = val.to_int::>().to_bytes(); + + let (x8_1, x8_2) = simd.split_u8x64(converted); + let (p1, p2) = simd.split_u8x32(x8_1); + let (p3, p4) = simd.split_u8x32(x8_2); + + let uzp1 = simd.unzip_low_u8x16(p1, p2); + let uzp2 = simd.unzip_low_u8x16(p3, p4); + simd.unzip_low_u8x16(uzp1, uzp2) +} + +/// A trait for implementing a fast approximal division by 255 for integers. +pub trait Div255Ext { + /// Divide by 255. + fn div_255(self) -> Self; +} + +impl Div255Ext for u16x32 { + #[inline(always)] + fn div_255(self) -> Self { + let p1 = Self::splat(self.simd, 255); + let p2 = self + p1; + p2 >> 8 + } +} + +impl Div255Ext for u16x16 { + #[inline(always)] + fn div_255(self) -> Self { + let p1 = Self::splat(self.simd, 255); + let p2 = self + p1; + p2 >> 8 + } +} + +/// Perform a normalized multiplication for u8x32. +#[inline(always)] +pub fn normalized_mul_u8x32(a: u8x32, b: u8x32) -> u16x32 { + (S::widen_u8x32(a.simd, a) * S::widen_u8x32(b.simd, b)).div_255() +} + +/// Perform a normalized multiplication for u8x16. +#[inline(always)] +pub fn normalized_mul_u8x16(a: u8x16, b: u8x16) -> u16x16 { + (S::widen_u8x16(a.simd, a) * S::widen_u8x16(b.simd, b)).div_255() +} + +/// Check if an affine transform is a pure integer translation. +/// +/// Returns true if the transform only contains integer translation (no rotation, +/// skew, or scaling), meaning rectangles will remain pixel-aligned after transformation. +#[inline] +pub fn is_integer_translation(transform: &Affine) -> bool { + let [a, b, c, d, e, f] = transform.as_coeffs(); + (a - 1.0).is_nearly_zero() + && b.is_nearly_zero() + && c.is_nearly_zero() + && (d - 1.0).is_nearly_zero() + && (e - e.round()).is_nearly_zero() + && (f - f.round()).is_nearly_zero() +} +/// Check if an affine transform has no skewing (i.e. preserves axis alignment). +#[inline] +pub fn is_axis_aligned(transform: &Affine) -> bool { + let [_, b, c, ..] = transform.as_coeffs(); + b.is_nearly_zero() && c.is_nearly_zero() +} + +/// Extract scale factors from an affine transform using singular value decomposition. +/// +/// Returns a tuple of (`scale_x`, `scale_y`) representing the scale along each axis. +/// This uses the same algorithm as kurbo's internal `svd()` method. +/// +/// # Arguments +/// * `transform` - The affine transformation to extract scales from. +/// +/// # Returns +/// A tuple `(scale_x, scale_y)` with minimum values clamped to 1e-6 to avoid division by zero. +/// +/// # Note +/// TODO: Consider making `Affine::svd()` public in kurbo to avoid duplicating this code. +/// This implementation mirrors kurbo's internal SVD calculation for extracting scale factors +/// from arbitrary affine transformations. +#[inline] +pub fn extract_scales(transform: &Affine) -> (f32, f32) { + let [a, b, c, d, _, _] = transform.as_coeffs(); + let a = a as f32; + let b = b as f32; + let c = c as f32; + let d = d as f32; + + // Compute singular values using the same formula as kurbo's svd() + let a2 = a * a; + let b2 = b * b; + let c2 = c * c; + let d2 = d * d; + let s1 = a2 + b2 + c2 + d2; + let s2 = ((a2 - b2 + c2 - d2).powi(2) + 4.0 * (a * b + c * d).powi(2)).sqrt(); + + let scale_x = (0.5 * (s1 + s2)).sqrt(); + let scale_y = (0.5 * (s1 - s2)).sqrt(); + + (scale_x.max(1e-6), scale_y.max(1e-6)) +} diff --git a/vendor/winit/src/platform_impl/macos/view.rs b/vendor/winit/src/platform_impl/macos/view.rs index 13cc718f65..2423d5724c 100644 --- a/vendor/winit/src/platform_impl/macos/view.rs +++ b/vendor/winit/src/platform_impl/macos/view.rs @@ -144,6 +144,8 @@ pub struct ViewState { marked_text: RefCell>, accepts_first_mouse: bool, + /// PdfCraft patch: answers `mouseDownCanMoveWindow` (see there). + movable_by_window_background: bool, // Weak reference because the window keeps a strong reference to the view _ns_window: WeakId, @@ -882,6 +884,17 @@ declare_class!( trace_scope!("acceptsFirstMouse:"); self.ivars().accepts_first_mouse } + + // PdfCraft patch: a press on this view never moves the window unless the window asked to + // be movable by its background. NSView answers YES when it isn't opaque, so with a + // transparent title bar and a full-size content view AppKit moved the window for any drag + // that started in the title-bar band, before the app saw it: tabs drawn there could not + // be dragged. Apps that draw their own title bar move the window with `drag_window`. + #[method(mouseDownCanMoveWindow)] + fn mouse_down_can_move_window(&self) -> bool { + trace_scope!("mouseDownCanMoveWindow"); + self.ivars().movable_by_window_background + } } ); @@ -890,6 +903,7 @@ impl WinitView { app_delegate: &ApplicationDelegate, window: &WinitWindow, accepts_first_mouse: bool, + movable_by_window_background: bool, option_as_alt: OptionAsAlt, ) -> Retained { let mtm = MainThreadMarker::from(window); @@ -911,6 +925,7 @@ impl WinitView { committed_in_key_down: Default::default(), marked_text: Default::default(), accepts_first_mouse, + movable_by_window_background, _ns_window: WeakId::new(&window.retain()), option_as_alt: Cell::new(option_as_alt), }); diff --git a/vendor/winit/src/platform_impl/macos/window_delegate.rs b/vendor/winit/src/platform_impl/macos/window_delegate.rs index e293505ebe..86587bdcfa 100644 --- a/vendor/winit/src/platform_impl/macos/window_delegate.rs +++ b/vendor/winit/src/platform_impl/macos/window_delegate.rs @@ -634,6 +634,7 @@ fn new_window( app_delegate, &window, attrs.platform_specific.accepts_first_mouse, + attrs.platform_specific.movable_by_window_background, attrs.platform_specific.option_as_alt, );